§1 — What is actually missing, and why a field is not enough

The published schema carries 41 properties and no tag field. Its only grouping axes — sprint, milestone, tier, north_star — are single-valued and lifecycle-shaped. None expresses "this is standard-names work". So directories are the only topical grouping in the system, and the layout migration deletes them: 73 standard-names documents flatten, 71 of whose slugs do not carry the topic, leaving 09-llm-compose.html beside gpu-cluster-scoping.html with nothing marking it. Three independent numbered sequences each run their own 01..46 and interleave into meaninglessness.

The minimal answer is a comma-separated meta field, and it would fail within a week. An author who cannot enumerate the existing tags invents one, and the corpus fragments into standard-names, standard_names, sn and standardnames as four unrelated groupings — which is not hypothetical, it is the same shape already paid for twice in a consuming project. A grouping mechanism whose values diverge is worse than none, because it looks like it is working.

So the design problem is not "how do we store a list of strings". It is: what makes a vocabulary stay coherent when every author is a different agent or session, none of them coordinates with the others, and nobody will perform governance. Four properties answer that, and each is a section below: one canonical identity per tag enforced where writes happen (§2); the vocabulary visible with usage counts at the moment of authoring, which is the whole design (§3); and a rename that is one command rather than N hand edits, because otherwise the first typo is permanent (§5). §4 records the thing this plan deliberately does not build.

Two decisions on this are already locked on sprint-scope-and-surface and are inherited rather than re-opened: the inventory is observed usage with counts, not a curated allow-list, and tags and their backfill precede the layout migration. This plan builds the system those decisions assume.

§2 — One canonical identity, enforced at write

This is the load-bearing mechanism and the one that makes the vocabulary stable. A tag's stored identity is derived, not accepted: casefold, trim, collapse internal whitespace and underscores to a single hyphen. Divergent spellings of the same intent converge before they are ever stored, so they cannot become separate groupings.

Four spellings — standard-names, Standard_Names, standard names, standardnames — converge through a casefold, trim and separator-normalising step into the single stored identity standard-names with four documents. A fifth authored value, sn, is not a spelling variant so it stays a separate identity with one document and is flagged as a singleton by audit.

Normalisation collapses spelling variance, and only spelling variance. An abbreviation is a different word, so it survives as its own identity — and is surfaced as a singleton rather than silently merged, because merging it would be a guess about intent.

Normalisation is applied, not merely validated. Rejecting Standard_Names with an error teaches the author nothing and blocks a write that had no ambiguity in it. Silently accepting it creates the second grouping. Applying the canonical form and reporting what was stored does both jobs. A value that cannot be normalised into a non-empty identity — punctuation only, or empty after trimming — is a genuine error and is refused.

The grammar reserves what it does not yet use. Every tag known today is a topic, so a flat vocabulary is sufficient now. But : is reserved from the first commit, so a later faceted form such as machine:iter is additive rather than a migration across the whole corpus. Reserving a separator costs one validation rule today; retrofitting one costs a rewrite of every tagged document. This is the difference between a system and a patch, and it is cheap only if it is done now.

Done when a property test asserts that normalising any two spelling variants of the same intent yields byte-identical identities, that normalisation is idempotent, that an unnormalisable value is refused with a message naming the offending input, and that : is refused with a message stating it is reserved.

§3 — Tags on every typed resource, and the inventory in front of the author

Tags belong on plans, research, evidence and sprints alike. The 73 documents that motivate this plan are research, not plans. A tag field that only works on plans would leave the exact corpus that needs grouping without it — and would make a cross-type query, which is where topical grouping earns its keep, impossible to express.

The field follows the established comma-separated meta pattern that depends_on and informs already use, so it needs no new parsing convention and travels through the same typed-resource machinery.

The inventory is computed, never stored. read_plan(project) already returns a discovery envelope; the tag inventory joins it, derived live across plans, research and evidence with a usage count per tag. A count is what makes a one-off typo visible beside an established tag rather than equal to it — standard-names (73) next to standrd-names (1) tells the reader everything without any governance having happened.

And it must be in front of the author at the moment of authoring. reckon-create is where a new resource's metadata is chosen. A skill that says "add tags" without showing which ones exist is precisely the instruction that produces four spellings. This is the cheap half of the whole plan and the half most likely to be dropped as a nicety.

Done when the discovery response carries every tag in use across all typed resources with its count; a test asserts the inventory is derived from the corpus rather than from any literal list, so a newly invented tag appears with no code change; and reckon-create surfaces the inventory at authoring time.

§4 — Why there is no tag filter, and what replaces the one you would build

An earlier draft of this plan proposed tag filtering in discovery, in roadmap, and as an SPA facet. That was scope, and it is cut. The reasoning is worth keeping so the next author does not add it back by reflex.

Retrieval is already free. read_plan(project) returns a discovery envelope carrying every resource with its parsed metadata, so once resources carry tags, the tags arrive with them. An agent wanting everything in a programme already holds the whole corpus and selects over it — a list comprehension, not a request. A server-side filter parameter would be a second way to obtain what the payload already contains, and a second way is a second thing to keep consistent with the first.

The authoring moment is the one that needed help, and it is a different problem. Directories did two jobs: they told an author where a thing belonged, and they let a reader enumerate a programme. Only the first was ever hard. Enumeration was always cheap and stays cheap. What was expensive — and what produced four spellings of the same tag — is an author choosing a value with no view of what the corpus already uses. The inventory with counts (§3) is the whole answer to that, and adding query surface does not improve it.

One property from that draft is real and moves to §3 rather than being lost: a resource must appear under every tag it carries, not only the first. That is a multivalued-ness assertion on the field and the inventory, and it is the failure a single-valued implementation produces — one that reads as working on any document that happens to carry exactly one tag, which most will.

Done when this plan ships no tag query parameter and no SPA tag facet, and the multivalued assertion is carried by §3's tests.

§5 — Rename, because the first typo is otherwise permanent

This is what separates a stable vocabulary from a frozen one. Observed usage with counts deliberately permits invention, which means mistakes and second thoughts are permitted too. Without a rename, correcting standrd-names means editing every carrying document by hand, so nobody does it and the corpus accumulates near-duplicates forever — the allow-list's failure mode arriving by a different road.

Deliverable: one command that renames a tag across every typed resource in a project, reporting the resources it will touch before touching them, and writing through the same version-safe path as any other state edit so a concurrent writer cannot be clobbered. It merges into an existing tag when the target already exists, since that is exactly the near-duplicate repair.

And the audit closes the loop. Singletons are surfaced as findings — the near-zero-cost governance the locked decision names — alongside pairs whose canonical identities differ only by a small edit distance, which catches standrd-names beside standard-names without forbidding either. Reported, never auto-merged: the system corrects spelling variance mechanically because that is decidable, and surfaces suspected intent variance to a human because that is not.

Done when renaming a tag rewrites every carrying resource in one version-safe pass, renaming onto an existing tag merges rather than duplicating, a dry run lists the affected resources without writing, and the audit reports both singletons and near-duplicate pairs with their counts.

§6 — Backfill, and the one thing that must happen before the layout migration

The backfill needs no judgement, which is the whole reason it must happen while the information still exists: the source directory is the tag. A document at research/standard-names/09-llm-compose.html carries the tag standard-names because that is what its location already asserted.

The pre-image is preserved at ~/.local/share/imas-codex/receipts/reckon-migration/layout-moves-preimage.txt, so the mapping is recoverable whenever it is taken. After the flattening it is recoverable only from git history, which is a materially harder and more error-prone read.

Done when every document the layout migration would move carries a tag derived from its pre-migration path, and a comparison against the saved pre-image proves zero grouping loss — an assertion in the suite rather than a judgement, failing if a single document would lose a programme it previously had.

A document can carry two tags and cannot live in two directories, so what replaces the directories is strictly more expressive than what it replaces. That is worth stating plainly, because the migration is otherwise easy to read as a loss being mitigated rather than an improvement being unlocked.

§ Decisions

Which resource types carry tags?

Locked 2026-08-24 by the corpus rather than by preference. The 73 documents that motivate this plan are research, not plans, so a plans-only field would leave the exact material that needs grouping without it — and would make the cross-type query, which is where topical grouping earns its keep, inexpressible. The field follows the existing comma-separated meta pattern used by depends_on and informs, so extending it across types costs no new parsing convention.

What happens when an authored tag is not in canonical form?

The four-spellings failure is the central risk. The choice is between refusing a non-canonical value, silently accepting it, or deriving the canonical identity from it.

Locked 2026-08-24. Accepting verbatim creates the second grouping, which is the failure being prevented. Refusing blocks a write that contained no ambiguity and teaches nothing at the moment it matters. Applying the canonical form and reporting it does both jobs: the corpus cannot fragment on spelling, and the author learns the convention from the response rather than from documentation. A value that normalises to nothing is still a genuine error and is refused. Read-time reconciliation was rejected outright because it makes stored identity ambiguous, so any consumer that does not run the same reconciliation sees a different corpus.

Does the tagging system add a query or filter surface, or is the inventory with counts the whole design?

An earlier draft of this plan proposed tag filtering in discovery, in roadmap, and as an SPA facet.

Locked by the lead 2026-08-24, cutting the filter as scope. Retrieval is already free: read_plan(project) returns a discovery envelope carrying every resource with its parsed metadata, so once resources carry tags an agent already holds the whole corpus and selects over it - a list comprehension, not a request. A filter parameter would be a second way to obtain what the payload already contains, and a second way is a second thing to keep consistent. Directories did two jobs, telling an author where a thing belonged and letting a reader enumerate a programme, and only the first was ever hard: enumeration was always cheap and stays cheap, while an author choosing a value with no view of existing usage is what produced four spellings. The inventory with counts answers that completely and query surface does not improve it. One property from the cut draft is retained in §3 rather than lost - a resource must appear under every tag it carries, not only the first.

Does the grammar reserve a facet separator now, and does it accept facets on day one?

Every tag known today is a topic, so a flat vocabulary is sufficient. The question is headroom: reserving : now costs one validation rule, while retrofitting a separator later costs a rewrite of every tagged document. Accepting facets on day one costs more than reserving and may never be used.

Locked by the lead 2026-08-24. Reserving the separator costs one validation rule today and makes a faceted form purely additive later; retrofitting a separator once the corpus is tagged means rewriting every tagged document, so this is cheap now and never cheap again. Accepting facets on day one was rejected as design paid for before any evidence a second facet is wanted, and because a facet vocabulary needs its own inventory and governance, doubling the surface this plan is trying to keep small. A tag containing a colon is refused with a message stating the character is reserved, so the refusal teaches the rule rather than merely failing.

When the audit finds two tags whose identities differ by a small edit distance, what does it do?

Normalisation handles spelling variance mechanically because that is decidable. A near-duplicate such as standrd-names beside standard-names is a suspected typo, which is a guess about intent rather than a derivable fact.

Locked by the lead 2026-08-24. A warning with no adjacent action is the kind that gets scrolled past, and the entire argument for observed-usage-with-counts over a curated allow-list was that governance would be near-zero cost - which only holds if the repair is one paste rather than a research task. Emitting the exact rename invocation beside both tags and their counts turns a finding into a fix. Auto-merge was rejected because it is a guess about intent with no undo, and it fails precisely where the author was right: a genuinely new tag that happens to resemble an established one.

§ Followups

Build the vocabulary mechanism before the field, then backfill before the flatten

Normalisation first, since it is what keeps the vocabulary coherent when every author is a different session and nobody performs governance — applied and reported rather than refused or accepted verbatim. Then the field across every typed resource, because the 73 documents that motivate this are research and a plans-only field misses them entirely. Then the computed inventory with counts, in the discovery envelope and in front of the author at reckon-create. Then filtering, because a grouping you cannot query is decoration. Then rename plus the audit's singleton and near-duplicate findings, which is what stops the first typo becoming permanent. The backfill from the saved pre-image lands last but must precede the layout migration, and the zero-grouping-loss comparison is an assertion in the suite rather than a judgement. Two decisions are open for the lead: whether the grammar reserves a facet separator now, and what the audit does with a near-duplicate pair.

/reckon-ship resource-tagging-system