Skip to content

Traceability identifier conventions

The plexsphere repository carries several families of internal traceability identifiers — planwerk feature ids, requirement ids, story ids, and review-item codes. They exist for the team's own audit trail and never leave the repository's internal channels. This page is the operator-side reference for which surfaces may carry an identifier and which must not, plus the regression gate that enforces the rule.

Identifier families

FamilyShapeExample
planwerk featurePX-NNNNPX-0041
numeric requirementREQ-NNNREQ-005
long-form requirementREQ-PX-NNNN-NNNREQ-PX-0041-002
storyS0NNS014
review item — warningW-NNNW-001
review item — infoI-NNNI-003
review item — changeC-NNNC-002
review item — bugB-NNNB-004
GitHub issue / PR / review reference(issue|PR|GH issue|GitHub issue|review) #NNNissue #181, PR #136 review #1

The GitHub-reference family is keyword-anchored on purpose: bare #NNN patterns appear in unrelated technical contexts (PKCS #11, SQL # DDL, heading-style numbering) and would false-fire without the leading keyword.

Where identifiers may appear

Identifiers may live only inside real language-level comment syntax. The recognised forms:

LanguageComment form
Go// ... and /* ... */
Python# ...
TypeScript, JavaScript, CSS// ... and /* ... */
YAML, shell, Makefile, Dockerfile# ...
HTML, Markdown<!-- ... -->

Inside one of these comment forms, identifiers are encouraged when they carry value: the DECISION: and TODO(<scope>, <id>): blocks that anchor the team's audit trail belong here. The previous (REQ-NNN, PX-NNNN) traceability suffix on Go error / panic / test-failure messages is retired — those strings reach human readers (operators on kubectl logs, developers reading go test output) and the identifier is opaque to them.

Where identifiers must NOT appear

Anywhere that is not a real comment is out of scope. In particular:

  • Markdown body — prose, headings, list items, table cells, ASCII diagrams.
  • Markdown frontmatter — including the feature: key. Frontmatter values render into the page <title> and <meta> tags VitePress emits, the sidebar tooltip, the search index snippet, and any themed layout that surfaces frontmatter. Frontmatter that carries a planwerk task id reaches readers outside the repository's internal channels.
  • Dashboard UI strings — component text, labels, ARIA labels, error copy, anything under web/src/ that ends up on the user's screen.
  • Test namesdescribe(...), it(...), test(...), t.Run(...) titles. They appear in CI run summaries and IDE test explorers. Test trace suffixes belong in the test body's failure message, not the title.
  • Test filenames — a file called idp_bindings_doc_drift_test.go carries the identifier in every go test -v line and git log entry. Name the file after what it tests, not what produced it.
  • Fixture payloads, golden files, snapshot data — JSON, YAML, .golden, or generated fixtures whose contents are read by a test or shipped to a downstream consumer.
  • OpenAPI spec stringsdescription, summary, title, tags. These render into the generated ReDoc UI and the TypeScript / Go client doc comments.
  • CLI surfaces — flag help text, error messages emitted to stderr, banner lines, log lines, embedded copy. Operators read these without access to planwerk.
  • Bootstrap binaries and seed programscmd/bootstrap* and equivalents. The exit-code commentary, the flag help, and the log lines all reach the operator.
  • Generated artefacts that get committed (e.g. vendored ReDoc output, generated TypeScript) — regenerate sources so the identifier no longer appears in the output.

Moving an identifier into a comment

When the trace is genuinely useful, do not delete it — move it into the nearest valid comment form. The three common shapes:

Markdown — HTML comment under the H1

markdown
---
title: Manage domain settings
description: Operator how-to for renaming a Domain, rotating its IdP binding, and rolling the per-Domain signing key.
status: stable
audience: operator
quadrant: how-to
---

<!-- traceability: PX-0024 -->

# Manage domain settings

YAML — # comment on the line above

yaml
# traceability: PX-0026, REQ-005 — Domain CRUD HTTP surface
metadata:
  name: domains-test

Go / TypeScript — // comment on the line above

go
// traceability: PX-0006, REQ-001 — Domain aggregate invariants
type Domain struct { ... }

Regression gate

The repository-level gate that enforces this convention is the TestNoIdentifiersInRenderedSurfaces test in tests/workspace/no_identifiers_in_rendered_surfaces_test.go. It:

  • Walks every tracked file via git ls-files (honours .gitignore).
  • Routes each path through a per-extension scan profile that strips the language's comment syntax.
  • Scans every tracked file's basename for an embedded identifier.
  • Reports each leak as path:line: <identifier> so failures are actionable.

Run it locally:

shell
make check-rendered-identifiers

When the gate fails, the output names each leak and includes a per-extension ↳ move to: ... hint with the exact comment- syntax substitution to apply. To strip trailing (PX-NNNN, REQ-NNN) parentheticals mechanically — across a list of files at once — use the sweep helper:

shell
# dry run — list files the gate flags
make sweep-rendered-identifiers

# rewrite specific files
make sweep-rendered-identifiers PATHS="docs/foo.md cmd/plexctl/main.go"

The sweep helper is conservative on purpose: it strips trailing parentheticals, but it does NOT delete feature: PX-NNNN frontmatter keys, rename test files, or touch JSX text. Those rewrites need human judgement about how the surrounding context should read; the gate flags them, the reviewer fixes them, the helper stays out.

The gate ships with a deliberately narrow allowlist for the handful of files where identifiers are intrinsic to the file's purpose (the gate file itself, this reference page, the .planwerk/ planning corpus, the changelog audit trail). Each entry carries an inline justification in the test source. New allowlist entries require a written rationale that survives review.

Allowlist policy

Two questions decide whether a new allowlist entry is justified or whether the file should be cleaned instead:

  1. Is the identifier intrinsic to the file's purpose? A planwerk task-definition JSON under .planwerk/ is keyed by PX-NNNN; stripping the id would erase the data. A docs page's feature: PX-NNNN frontmatter is metadata, not data — strip it.
  2. Is the user-rendered surface load-bearing? A release- notes entry that summarises "what shipped in v0.5" leans on the planwerk-id linkage; stowing it in an invisible HTML comment defeats the changelog's purpose. A dashboard page's help-text paragraph does not — readers without tracker access cannot click through, so the id only confuses.

The current allowlist is short enough to audit in one pass:

PathReason
tests/workspace/no_identifiers_in_rendered_surfaces_test.goSelf — the gate file embeds identifier shapes in its docs and regex
docs/contributing/traceability-conventions.mdConvention reference — quotes the identifier shapes the rule forbids elsewhere
docs/contributing/authoring-conventions.mdConvention reference — quotes the identifier shapes in the docs corpus rule
CLAUDE.mdContributor instructions — quotes TODO(<scope>, PX-XXXX) and REQ-XXX verbatim
CHANGELOG.mdRelease notes — both rendered AND audit-trail; per-entry inline planwerk-id linkage is the changelog's purpose
.planwerk/ (prefix)Planwerk planning system data — task ids are the primary key, not rendered prose

Adding a new entry requires updating both the gate file's allowlist map and the table above in lockstep, with a written rationale that survives review.

Scope evolution

The gate is delivered in phases so the cleanup it requires can land alongside the per-surface sweeps. The current scope:

  • Phase 1 — Markdown files (body + frontmatter, stripping HTML comments, fenced code, and inline code spans) and tracked filenames.
  • Phase 2a — TS/TSX test titles (describe(...), it(...), test(...), and their .skip / .only / .each / .todo / .concurrent / .sequential variants). Only the first string argument is inspected; assertion-failure messages elsewhere in the test body are treated like Go t.Errorf strings — internal CI trace, not rendered output.
  • Phase 2d — OpenAPI spec YAML under api/openapi/. The # comment syntax is stripped before scanning; everything else (description / summary / title / tags string values, schema names, example payloads) is checked. The spec drives the generated ReDoc UI, the Go and TS client doc-comments, and third-party swagger-ui consumers — every string in the body reaches end-users.
  • Phase 2f — bootstrap binary production Go source under cmd/plexsphere-bootstrap/. Production files (not *_test.go) are scanned with Go comments stripped; string literals, log markers, error wrappers, and flag-help strings are checked. These reach operators via stderr and kubectl logs. The companion *_test.go files keep the per-package t.Errorf Feature-ID trace (internal CI trace, not rendered output) and are not routed through this profile.
  • Phase 2g — .golangci.yml and .github/workflows/*.yml. The lint config's desc: strings surface in golangci-lint failure output and CI run logs; the workflow name: / run: values surface in the GitHub Actions UI and in gh run view. Both files are routed through the same scanYAMLHashComments profile that already covers the OpenAPI spec.
  • Phase 2b — TS / TSX const ...FEATURE... = "..." declarations whose value carries an identifier. The repo's spec files used the pattern to thread an identifier blob into test titles via ${FEATURE} template-literal interpolation. The Phase 2a title scanner only inspects literal title characters, so the ${FEATURE} placeholder escaped the rule even though the resolved runtime title still carried the planwerk id.
  • Phase 2c — user-visible TS / TSX surface under web/src/ (everything outside __tests__/). The profile strips both C-style (// ..., /* ... */) and JSX ({/* ... */}) comments before scanning. Internal DECISION: and TODO(scope, PX-NNNN, …): blocks still pass; identifier- bearing JSX text, attribute values, or rendered string literals do not.
  • Phase 2e — chainsaw E2E test YAML under tests/e2e/. Step name: values, shell echo strings inside run: blocks, Kubernetes resource label entries, and SQL -- comments inside multi-line script bodies all surface in chainsaw run output and CI logs. The same scanYAMLHashComments profile that already covers the OpenAPI spec and the lint / CI YAML is routed against this surface.
  • Phase 3c — operator-facing cmd/plexctl/ Go production source. plexctl is the operator-facing CLI; every string literal in its production source can reach the operator via stderr or --help output, so the convention's rendered- surface rule applies. *_test.go files keep the per-package t.Errorf Feature-ID trace (internal CI trace, not rendered output) and are NOT routed through this profile.
  • Phase 4b.1 — chainsaw-driven test-fixture binary source under cmd/identity-e2e-demo/, cmd/tenancy-e2e-demo/, cmd/signer-e2e-demo/, cmd/sse-stub-plexd/, cmd/messaging-publisher/, and cmd/messaging-replayer/. These binaries do not ship to operators — no Helm chart references them — but their stdout / stderr is what chainsaw scrapes to validate E2E suite outcomes, and that log surfaces in CI failure reports that engineers read. Production server binaries (cmd/plexsphere/, cmd/plexsphere-signer/) are NOT in this list — their structured slog output serves a legitimate operator-log- scraping convention that needs a separate design conversation.
  • Phase 4b.2–6 — small edge surfaces. The gate now routes schema/authz.zed and api/proto/*.proto through a Go- comment-stripping scanner (both DSLs use // and /* … */); *.sql migrations under internal/platform/db/migrations/ through an SQL comment-stripping scanner (handles -- … line comments and /* … */ block comments — comments stay allowed, RAISE EXCEPTION strings and DDL must be clean); the root Makefile through a Makefile-aware scanner that honours both Make-level # comments and recipe-level @# silent shell comments. The 10 RAISE EXCEPTION strings in the SQL migration ledger and the one ## help text in the Makefile were stripped to satisfy the new scans.
  • Phase 4c.4 — generated Go artefacts. sqlc output under internal/platform/db/gen/, oapi-codegen output under internal/transport/http/v1/server/, and buf-protoc output under pkg/proto/ are routed through the comment-stripping Go scanner. Generated source frequently propagates planwerk ids from the source-of-truth (.sql query header, .proto leading comment, OpenAPI description), which legitimately lives in // comments; the gate verifies the non-comment generated output stays clean even as make generate regenerates the files.

Every surface named in the originating issue is now covered. The phasing is a property of which surfaces the gate inspects, not of the rule itself — the rule applies to every surface from day one.

FAQ

Why is t.Errorf("(REQ-X, PX-Y)") allowed but t.Run("(REQ-X, PX-Y)") is not?

Both strings are syntactically identical — both are Go string literals inside a Go test file, neither lives in a // comment — but the convention treats them differently because of where they end up at runtime:

  • A t.Errorf failure message surfaces only when the test fails. Its reader is the CI scraper running grep REQ-006 ci.log or the contributor reading the failing-test panel in their IDE. That is exactly the audience the trace is for: someone on the internal channels who benefits from a grep-able identifier.
  • A t.Run title surfaces on every run, passing or failing, in the test-summary panel, in go test -v output piped to a CI step name, and in IDE test-explorer breadcrumbs. The surface is exposed continuously, not just on a fault, so it crosses the convention's "rendered output" boundary.

The same principle separates the surfaces named in the gate's Phase-2 sweeps:

  • A fmt.Errorf inside an internal Go package that the dashboard never reads is internal trace. The convention allows the identifier suffix.
  • A fmt.Errorf inside cmd/plexsphere-bootstrap/ whose wrapped error lands in stderr that an operator reads via kubectl logs is operator-visible. The convention forbids the suffix.
  • A describe(...) title in a vitest spec is operator-visible (vitest UI, playwright HTML report). Forbidden.
  • A expect(...).toBe(value, "(REQ-X, PX-Y) message") assertion message is internal CI trace. Allowed.

When in doubt, ask: does this string surface in a passing run, or only when something breaks? Passing-run surfaces (titles, descriptions, JSX text, OpenAPI fields, CLI help) must not carry identifiers. Failure-only surfaces (test error messages, internal log lines that never escape the process) are exempt.

Why does tests/workspace/feature_id_traceability_test.go retire some gates rather than just deleting them?

The two gates the file retired — the binding-error trace enforcement and the publish-marker trace enforcement — both asserted "every error string in cmd/plexsphere-bootstrap/ carries the canonical (REQ-PX-NNNN-NNN, PX-NNNN) suffix." That contract directly contradicted the rendered-surface rule the Phase 2f sweep introduced. A reader running git blame or git log on the deleted gates without context would assume the trace promise was simply abandoned. The retirement comment in the test file names the alternative trace pointers (the package doc-comment, the per-sentinel comments, the rendered- surface gate) so the audit-trail invariant is still grep-able from the file the convention deleted.

The new rendered-surface gate covers every surface from the originating issue — what is the meta-gate test for?

TestRenderedIdentifierGate_CatchesSyntheticViolation and its two siblings in tests/workspace/no_identifiers_in_rendered_surfaces_self_test.go prove the gate works. A passing run of the umbrella gate can mean either "the tree is clean" or "the scanner is silently broken (returns zero matches for every input)". The self-test feeds each scanner a known-violating payload and a known-allowed payload, asserts the obvious outcomes, and locks the router decisions under review. Together they convert the umbrella gate's passing run from "trust me" into "verified against a synthetic fixture set every CI run."

Cross-references

  • Docs authoring conventions — the per-doc rules (sentence-case headings, frontmatter contract) that complement the identifier rule for the docs corpus.
  • Testing guide — the rendered-surface rule on every error / panic / test-failure message, with examples of the convention-compliant comment-block carrying the trace.