Appearance
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
| Family | Shape | Example |
|---|---|---|
| planwerk feature | PX-NNNN | PX-0041 |
| numeric requirement | REQ-NNN | REQ-005 |
| long-form requirement | REQ-PX-NNNN-NNN | REQ-PX-0041-002 |
| story | S0NN | S014 |
| review item — warning | W-NNN | W-001 |
| review item — info | I-NNN | I-003 |
| review item — change | C-NNN | C-002 |
| review item — bug | B-NNN | B-004 |
| GitHub issue / PR / review reference | (issue|PR|GH issue|GitHub issue|review) #NNN | issue #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:
| Language | Comment 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 names —
describe(...),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.gocarries the identifier in everygo test -vline andgit logentry. 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 strings —
description,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 programs —
cmd/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 settingsYAML — # comment on the line above
yaml
# traceability: PX-0026, REQ-005 — Domain CRUD HTTP surface
metadata:
name: domains-testGo / 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-identifiersWhen 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:
- Is the identifier intrinsic to the file's purpose? A planwerk task-definition JSON under
.planwerk/is keyed byPX-NNNN; stripping the id would erase the data. A docs page'sfeature: PX-NNNNfrontmatter is metadata, not data — strip it. - 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:
| Path | Reason |
|---|---|
tests/workspace/no_identifiers_in_rendered_surfaces_test.go | Self — the gate file embeds identifier shapes in its docs and regex |
docs/contributing/traceability-conventions.md | Convention reference — quotes the identifier shapes the rule forbids elsewhere |
docs/contributing/authoring-conventions.md | Convention reference — quotes the identifier shapes in the docs corpus rule |
CLAUDE.md | Contributor instructions — quotes TODO(<scope>, PX-XXXX) and REQ-XXX verbatim |
CHANGELOG.md | Release 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/.sequentialvariants). Only the first string argument is inspected; assertion-failure messages elsewhere in the test body are treated like Got.Errorfstrings — 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-partyswagger-uiconsumers — 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 andkubectl logs. The companion*_test.gofiles keep the per-packaget.ErrorfFeature-ID trace (internal CI trace, not rendered output) and are not routed through this profile. - Phase 2g —
.golangci.ymland.github/workflows/*.yml. The lint config'sdesc:strings surface in golangci-lint failure output and CI run logs; the workflowname:/run:values surface in the GitHub Actions UI and ingh run view. Both files are routed through the samescanYAMLHashCommentsprofile 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. InternalDECISION:andTODO(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/. Stepname:values, shellechostrings insiderun:blocks, Kubernetes resource label entries, and SQL--comments inside multi-line script bodies all surface in chainsaw run output and CI logs. The samescanYAMLHashCommentsprofile 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--helpoutput, so the convention's rendered- surface rule applies.*_test.gofiles keep the per-packaget.ErrorfFeature-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/, andcmd/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.zedandapi/proto/*.protothrough a Go- comment-stripping scanner (both DSLs use//and/* … */);*.sqlmigrations underinternal/platform/db/migrations/through an SQL comment-stripping scanner (handles-- …line comments and/* … */block comments — comments stay allowed,RAISE EXCEPTIONstrings and DDL must be clean); the rootMakefilethrough a Makefile-aware scanner that honours both Make-level#comments and recipe-level@#silent shell comments. The 10RAISE EXCEPTIONstrings in the SQL migration ledger and the one## helptext 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 underinternal/transport/http/v1/server/, and buf-protoc output underpkg/proto/are routed through the comment-stripping Go scanner. Generated source frequently propagates planwerk ids from the source-of-truth (.sqlquery header,.protoleading comment, OpenAPI description), which legitimately lives in//comments; the gate verifies the non-comment generated output stays clean even asmake generateregenerates 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.Errorffailure message surfaces only when the test fails. Its reader is the CI scraper runninggrep REQ-006 ci.logor 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.Runtitle surfaces on every run, passing or failing, in the test-summary panel, ingo test -voutput 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.Errorfinside an internal Go package that the dashboard never reads is internal trace. The convention allows the identifier suffix. - A
fmt.Errorfinsidecmd/plexsphere-bootstrap/whose wrapped error lands in stderr that an operator reads viakubectl logsis 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.