Development record
Plugin Metadata Stays Consistent Across Every Duplicated Surface
Closed as delivered on 2026-09-21 on the product thinker's ruling: the lockstep check (
launch.CheckLockstep, run by everylaunch --dry-run) shipped in v0.1.0 with per-field drift lines, the dev-tree absent-key rule and no bypass flag, while this record sat planned with no spec.
Press Release
abcd guards against version and changelog drift across its duplicated plugin metadata surfaces. The plugin's version and changelog live in more than one place — both
plugin.jsonlocations and.claude-plugin/marketplace.json— so a launch/version-bump workflow that updates one and forgets another leaves the published surfaces disagreeing. abcd ships a read-only consistency checker that proves the canonical metadata locations agree, refusing when they drift. The version WRITES stay with the launch/bump workflow; this is the anti-drift invariant that proves the writes landed everywhere they had to."A half-bumped plugin is worse than an un-bumped one — the marketplace says one thing and the plugin says another, and nobody notices until an install breaks," said Kira, a maintainer publishing a new build. "The lockstep check catches the disagreement before it publishes."
Drawn out from a human brief edit by the brief-change derivation gate (itd-61 / spc-75).
Why This Matters
Launch and version-bump workflows must update plugin metadata in lockstep at the
canonical plugin metadata locations, including both plugin.json files and
.claude-plugin/marketplace.json, so version and changelog state cannot drift
across duplicated surfaces. Where the version is WRITTEN is owned by the launch
flow (spc-77 / spc-80); what no surface owned before was the invariant that the two
manifests actually AGREE after a write. A drifted pair publishes a broken plugin:
the marketplace advertises one version, the plugin declares another, and the
mismatch surfaces only when a downstream install fails. A cheap read-only checker
turns that latent drift into an early, loud refusal.
What's In Scope
- A read-only consistency checker (module + CLI) that proves the ADR-pinned
metadata path set agrees across
plugin.jsonand.claude-plugin/marketplace.json, with an explicit--tree dev|publicargument (no auto-detection). - A policy ADR recording the lockstep invariant, the per-tree pinned path list,
and the
--allow-dirty-must-not-bypass rule (enforced downstream by spc-79/spc-80 wiring). - The published-marketplace changelog-entry schema the bump step consumes.
What's Out of Scope
- Version WRITES — owned by spc-77 / spc-80; this checker never bumps a version.
- Preflight WIRING of the checker into the launch gate suite — spc-79 / spc-80.
Scope Conditions
None stated.
Mechanism
None stated.
Acceptance Criteria
Given-When-Then per the itd-1 discipline.
- Given a public tree whose
plugin.jsonandmarketplace.jsonversions disagree across the ADR-pinned path list, when the checker runs with--tree public, then it refuses with per-field drift lines and a non-zero exit distinct from the consistent and contract-unreadable exits. - Given a dev tree that (per adr-19) must carry ABSENT version keys, when
the checker runs with
--tree dev, then a present version key is reported as drift and a correctly-absent key passes. - Given the checker binary, when it is inspected, then it exposes no dirty/skip bypass flag — manifest consistency cannot be waved through at its own layer.
Decisions
Ruled by the product thinker on 2026-09-21: close as delivered.
Open Questions
None open.
Audit Notes
Fidelity review — receipt rcp-2cf45c57ec66 (verifier intent-auditor claude-fable-5-1).
Provenance: intent-auditor@claude-fable-5-1 · rubric_hash sha256:effa65b3e9e88ff29433b443ec2be159522a8b0b71cf1434526514aa61edb13e · prompt_hash sha256:deb80d504ca7b06cab66460f9f830abbd03ac701f9552ad994fb4c4488af723a Input attestations: diff:internal/core/launch/lockstep.go, its two test files and internal/surface/cli at chore/audit-run-a-1 80b44890 (git ls-tree -r; spc-2609211957080006 closed, itd-69 shipped in v0.1.0)@sha256:11f3757706d47eee66e74275f84735bfaf8c3b41d56a37a624339073319e4fe9;
Acceptance rollup: MET 1 · MET_WITH_CONCERNS 2 · NOT_MET 0 · INCONCLUSIVE 0
Per-criterion verdicts:
- ac-1 — MET_WITH_CONCERNS: CheckLockstep under TreePublic compares the version field across the pinned manifest paths, returns one Drifts line per disagreeing field, and its ExitCode distinguishes agreement (0), drift (1) and an unreadable contract (2); tests cover agreement, drift, non-semver and the unreadable contract; the concern is that no
--tree publicfront door exists — the tree is a Go parameter, the public check runs only inside the payload render, and a public checkout cannot be checked from the CLI evidence: internal/core/launch/lockstep.go:21 — "TreePublic LockstepTree = "public"" evidence: internal/core/launch/lockstep.go:28 — "Drifts []stringjson:"drifts,omitempty"" evidence: internal/core/launch/lockstep.go:31 — "ExitCode intjson:"exit_code"// 0 ok, 1 drift, 2 unreadable" evidence: internal/core/launch/lockstep.go:51 — "func CheckLockstep(tree LockstepTree, repoRoot, versionLocationPath string) LockstepResult {" evidence: internal/core/launch/lockstep_test.go:45 — "func TestLockstepDrift(t *testing.T) {" evidence: internal/core/launch/lockstep_test.go:63 — "func TestLockstepBlockedContractUnreadable(t *testing.T) {" evidence: internal/core/launch/render.go:444 — "res.Lockstep = payloadLockstep(TreePublic, dest, vlPath)" - ac-2 — MET_WITH_CONCERNS: under TreeDev the check requires the version keys absent per adr-19 and reports a present key as drift with exit 1 while a correctly absent key passes with exit 0, as TestLockstepDevKeysAbsent holds; dry-run and ship run it over the source tree; the same concern applies — the dev tree is selected by the calling verb, not by a
--tree devflag evidence: internal/core/launch/lockstep.go:19 — "TreeDev LockstepTree = "dev"" evidence: internal/core/launch/lockstep_test.go:74 — "func TestLockstepDevKeysAbsent(t *testing.T) {" evidence: internal/core/launch/dryrun.go:114 — "lockstep := CheckLockstep(TreeDev, req.RepoRoot, vlPath)" evidence: internal/core/launch/ship.go:80 — "lockstep := CheckLockstep(TreeDev, req.RepoRoot, vlPath)" - ac-3 — MET: the checker takes no skip or dirty argument and the launch verb exposes no flag that reaches it; dry-run folds every drift line and an unreadable contract into WouldRefuseOn unconditionally, ship folds the same into BlockReasons, and the render refuses public-payload drift with ErrPayloadDrift —
--allow-dirtyconcerns the working tree, not manifest agreement evidence: internal/core/launch/dryrun.go:282 — "func wouldRefuseOn(bundle Bundle, scan scanner.ScanResult, lockstep LockstepResult, retention RetentionPlan, smoke SmokeReport) []string {" evidence: internal/core/launch/ship.go:102 — "report.BlockReasons = wouldRefuseOn(bundle, scan, lockstep, report.Retention, report.Smoke)" evidence: internal/core/launch/render.go:446 — "return res, fmt.Errorf("%w: %s", ErrPayloadDrift, strings.Join(lockstepDetail(res.Lockstep), "; "))"
Gap audit:
- honoured:
- per-field drift with three distinct exit outcomes
evidence: internal/core/launch/lockstep.go:31 — "ExitCode int
json:"exit_code"// 0 ok, 1 drift, 2 unreadable" - the dev tree carries absent version keys and a present one is drift evidence: internal/core/launch/lockstep_test.go:74 — "func TestLockstepDevKeysAbsent(t *testing.T) {"
- no bypass at the checker's own layer evidence: internal/core/launch/ship.go:102 — "report.BlockReasons = wouldRefuseOn(bundle, scan, lockstep, report.Retention, report.Smoke)"
- per-field drift with three distinct exit outcomes
evidence: internal/core/launch/lockstep.go:31 — "ExitCode int
- diverged:
- the checker runs with
--tree publicor--tree devevidence: internal/core/launch/lockstep.go:51 — "func CheckLockstep(tree LockstepTree, repoRoot, versionLocationPath string) LockstepResult {" evidence: internal/core/launch/render.go:444 — "res.Lockstep = payloadLockstep(TreePublic, dest, vlPath)"
- the checker runs with
- missing: (none)
Linkage note (spc-83.5)
Ships as one of FOUR intents sharing spec
spc-83-operator-surfaces-manifest-lockstep. abcd represents "N intents, one
spec" as a bundle (kind: bundle-member + shared bundle: spc-83-operator-surfaces)
— the representation the doc_fidelity intent-resolution + spec-close preflight
require. Bundle member by delivery relationship, not a scope change. The grill/PRD
bypass for this ungrilled intent is handled via the grandfather fields
(prd_grandfathered for GR002; two-key grandfathered + grandfathered_at_phase
for GR001). Full record in the spec's process-exception note.
Grounds
- pursued: the record is being closed for work v0.1.0 carried so the store matches what ships; shown wrong if the lockstep check is found not to meet the criteria this record keeps
Frontmatter
| id | itd-69 |
|---|---|
| type | intent |
| lifecycle | shipped |
| kind | standalone |
| severity | minor |
.abcd/development/intents/shipped/itd-69-plugin-metadata-lockstep-update.md
open on GitHub ↗ · commit history ↗
References1
- itd-67abcd Is An Installable, Versioned Claude Code Plugin Whose Repo Is Its Own Marketplace And Whose Every Launch Bumps, Tags, And Publishes A New Versionbuilds on
Referenced by6
- adr-20The two release manifests stay version-consistent by an anti-drift checker over a pinned path list; the source view stays unversioned; `--allow-dirty` must never bypass itrelated
- iss-2610262032157319itd-69's checker takes `--tree dev|public`; the platform page says there is no dev→public mirrorrelated
- iss-2610262033194284itd-69 says the version lives in both plugin.json locations plus marketplace.json; 04-launch says two locations are the whole of itrelated
- iss-2610262040159117The configuration chapter says abcd is one repository with no dev-to-public mirror; itd-69's checker takes an explicit `--tree dev|public` argumentrelated
- iss-2610262044537770Shipped itd-69 says the brief-change derivation gate (itd-61) produced it; itd-61 is still a draft that has not yet given abcd that gaterelated
- spc-2609211957080006plugin-metadata-lockstep-updateimplemented by
mentions7
- adr-19The plugin version lives ONLY in the released artifact; the working tree stays unversioned, and the version location is chosen by a schema-validated decision, not hard-coded
- adr-34Intent lifecycle and phase scheduling are orthogonal axes
- iss-2609261423222935itd-69 ac-1 and ac-2 promise a checker that runs with --tree public and --tree dev. Delivered: launch.CheckLockstep (internal/core/launch/lockstep.go:51) takes the tree as a Go parameter and has no front door — dry-run and ship call it with TreeDev over the source tree (internal/core/launch/dryrun.go:114, ship.go:80) and only the payload render calls it with TreePublic over its own output (internal/core/launch/render.go:444). A public checkout — a marketplace install, a release source archive — cannot be checked for manifest lockstep from the CLI or the plugin surface at all. Wanted: a read-only verb or flag that runs CheckLockstep with a chosen tree against a named root and exits with its 0/1/2 code.
- iss-2610021502084298No check meets the retired role word in an intent's press release at the record: the docs-lint rule roles/retired-role-word roots at the docs, the command pages and the rules, so a shipped or planned intent's press release carrying it is refused only when the site composer renders it as the landing page's hero quote, which is how itd-60's first MET audit broke the site-render gate (iss-2610021446271464). After the R5 reword of itd-60, thirteen shipped press releases still carry the word (itd-65, itd-66, itd-67, itd-69, itd-73, itd-74, itd-93, itd-102, itd-111, itd-114, itd-125, itd-2609111003026787, itd-2609212137129937), so the next MET audit ingested for any of them refuses the render again on a branch that only recorded an audit; the product thinker's ruling R5 (2026-10-02) accepted that a future slip breaks the build and did not ask for this check. Related to iss-2610020731591808, which names the brief, the principles and the personas registry as swept but unguarded.
- itd-1Every Intent Has Acceptance Criteria; Every Audit Verifies Them
- itd-61A Human Edit To The Brief Cannot Commit Until Its Implied Intents And Principles Are Drawn Out And Reconciled
- itd-105A fresh plugin install just works: the hook binary survives install and every update