fix(runtime): AppPlugin's bundle path consumes the artifact door's ADR-0087 forward conversion - #12878
Merged
os-litant merged 10 commits intoAug 28, 2026
Merged
Conversation
…artifact-ingestion door Artifacts built by released 17.x tooling carry then-legal keys (allowRestore/ allowPurge, retired in spec 17.2.0) and were refused by the strict parse in MetadataPlugin._parseAndRegisterArtifact — the retiredKey tombstone fired with no operator remedy, since 'os migrate meta' targets sources, not built artifacts. New policy in @objectstack/metadata-core (applyArtifactForwardConversions): replay the full ADR-0087 conversion chain — retired entries included — over an artifact whose declared engines.protocol floor predates the running spec version; an artifact authored at the current (or newer) surface converts nothing and still answers to the tombstone. Versioned, not a blanket strip: the retired keys return with M2 (#1883), and artifacts authored against that surface must never be stripped by history. The door (MetadataPlugin) applies it before every strict parse — bare definitions and environment-artifact envelopes — and surfaces notices operator-visibly, deduped per conversion per artifact, modeled on the stored-row pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVYmuhHW6qZmNBqciaS7BN
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVYmuhHW6qZmNBqciaS7BN
…laration surface The artifact-forward-conversion module's public types referenced ConversionNotice from the @objectstack/spec root, so the emitted dist/index.d.ts imported the root entry - and every downstream type program reading metadata-core's declarations began loading the ~2MB spec root d.mts BESIDE the d.ts flavor it already read. Measured: the TEST_DEBT re-measure of packages/qa/http-conformance crossed CI's ~4GB tsc heap ceiling and OOM'd (listFiles diff between merge-base and branch: the only additions were spec/dist/index.d.mts and its chunk; the capped re-measure passes at the merge base and fails on the branch, same box, same command). Public surface now speaks ArtifactConversionNotice, a structural mirror pinned in both assignability directions in the module's test; the runtime applyConversions import stays and no longer reaches declaration emit. The rebuilt d.ts carries only the pre-existing spec/data subpath imports, and the capped (4096MB) http-conformance re-measure is green on this tree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVYmuhHW6qZmNBqciaS7BN
…al in the dispatcher vocabulary check:dispatcher-error-vocabulary flagged the new ArtifactConversionNotice.code literal in packages/metadata-core/src/artifact-forward-conversion.ts as an unclassified code-stamping site. Classified foreign-vocabulary beside the existing OS_METADATA_CONVERTED row for spec's apply.ts: the literal sits in a TYPE position of the structural mirror (declared to keep the spec ROOT import out of the package's public declaration surface), stamps nothing at runtime, and the notices flow to an onNotice callback exactly as in the classified spec site - nothing thrown, no envelope built. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVYmuhHW6qZmNBqciaS7BN
…tifact-conversion-door
…sal surface under the #12845 residue ruling Main landed acceptRetiredDefaultResidue (maintainer ruling 2026-08-28): a retired DEFAULTED key's emitted default parses as inert residue and strips silently at the schema layer; only a NON-default value keeps the #12497 tombstone. The real 17.1-built fixture carries exclusively the emitted default (150 x false, premise-guarded), so the old current-version refusal pin now describes a shape the ruled contract accepts - the merge queue measured exactly that on its merge tree. The pin is re-aimed, not weakened: current-version + NON-default value (true, authorable on the 17.1 surface, never emitted by a default) still refuses with the tombstone and prescription; and a new pin holds the other half of the ruling at the door - current-version + pure residue parses clean with the keys stripped by the SCHEMA layer while this door's versioned window stays closed (no conversion notice), keeping the door's amnesty versioned. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVYmuhHW6qZmNBqciaS7BN
`applyArtifactForwardConversions` — the function this card's fix consumes — does not exist on origin/main; it is added by PR #12843 (open, unmerged). Merged here so the AppPlugin change can be written and MEASURED against the same artifact door it must agree with. This merge disappears when #12843 lands.
…R-0087 forward conversion (#12844) The artifact boot reads the same bytes through two independent readers. `MetadataPlugin._parseAndRegisterArtifact` replays the versioned ADR-0087 forward conversion before its strict parse; `AppPlugin`'s ADR-0057 block received the same JSON from `loadArtifactBundle` and registered it RAW, so the two in-memory copies of the same permission set / sharing rule / position differed and which one a reader saw depended on registration order. AppPlugin now applies the door's own policy function to the same definition — one import, one call site, no conversion-specific knowledge on this side. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
Contributor
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
os-litant
marked this pull request as ready for review
August 28, 2026 07:09
This was referenced Aug 28, 2026
This was referenced Aug 28, 2026
github-merge-queue
Bot
removed this pull request from the merge queue due to failed status checks
Aug 28, 2026
os-litant
deleted the
claude/issue-12844-app-plugin-artifact-conversion
branch
August 28, 2026 13:52
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #12844
applyArtifactForwardConversionsdoes not exist onorigin/main. The cardstates it is "now exported from
@objectstack/metadata-core"; that is true onlyon the branch of #12843, which is open and unmerged. This branch therefore
carries a merge of PR #12843 at head
7eff8966so the change could be writtenand, more importantly, measured against the artifact door it has to agree
with. Until #12843 lands, the diff below shows both changes; my own commit is
3788bb5e9— 3 files, +436 / −2.Two consequences worth stating plainly, because they change what the card's
evidence means:
origin/main. There,the door performs no conversion at all, so both readers are raw and identical.
The two-reader divergence begins with feat(metadata): versioned ADR-0087 forward conversion at the artifact-ingestion door #12843.
artifact) is likewise a property of feat(metadata): versioned ADR-0087 forward conversion at the artifact-ingestion door #12843's branch: on
mainthat boot is theArtifacts built by released 17.x tooling are REFUSED by the 17.2 runtime: retired-key tombstones fire at artifact parse, and no artifact-ingestion door runs the ADR-0087 conversion that exists for exactly this #12772 refusal.
The change
AppPlugin's ADR-0057 security block registered the bundle fromloadArtifactBundleraw. It now applies the door's own policy function to thesame definition — one import, one call site, no conversion-specific knowledge on
this side. The whole definition is converted rather than a projection of the five
collections, deliberately: the
rolestopositionsentry rewrites acollection key, not an item, so a projection would silently miss it.
The conversion notices are logged at
debug, not surfaced: on an artifact bootthe door already prints one deduped operator summary per conversion for these
very bytes, and a second copy would double the boot log without adding a fact.
Reproduced first, on a real kernel boot of real released-17.1 bytes
The card says no defect is reachable today. That means no consumer reads the
difference — not that there is no difference. Measured, through both real readers
on one
kernel.bootstrap(), overpackages/metadata/src/__fixtures__/hotcrm-17.1-built-permissions.artifact.json(the manifest and permissions blocks verbatim from the artifact released
@objectstack/cli17.1.0 built, 75allowRestore+ 75allowPurgeoccurrences,engines.protocol^17.1.0):Both boots complete; neither refuses. The pre-fix number is the sharp part and it
is stronger than "the two copies differ": with the door registering first and
AppPluginsecond — the ordinary artifact-boot order — the raw copy winsoutright, so the door's forward conversion had no effect on the in-memory
permission registry at all. It converted 150 sites and every one of them was
overwritten.
Criterion 1 — all five collections, falsified individually
For each collection: an input carrying a retired or legacy shape, both readers
run over it, compared before and after. Two of the five cannot produce a
divergence, and that is a finding rather than a skip.
permissionspermission-allow-restore-purge-removed,permission-rls-priority-removed)allowRestore: true,allowPurge,rowLevelSecurity[0].priority: 10; door copy has them strippedsharingRulessharing-rule-access-level-full-to-edit,sharing-recipient-role-to-position)accessLevel: 'full'and recipienttype: 'role'; door copy has'edit'/'position'. Neither raw value is in the current enums, so that copy is unparseable at any re-validating seampositionsstack-roles-to-positions, a COLLECTION-key rename)positionson bytes spelling the collectionrolesand registers nothing; door registers the positioncapabilitiescapabilitiesis an authorable stack collection (ADR-0066 D1) thatARTIFACT_FIELD_TO_TYPEinpackages/metadata/src/plugin.tsdoes not map, so the door registers nothing undercapabilityandAppPluginis the sole registrarpoliciesObjectStackDefinitionSchemais astrictObjectwith no top-levelpolicieskey (on the permission set,policiesis an alias forrowLevelSecurity). A top-levelpoliciescollection is refused outright, so it reaches neither registryBoth
ARTIFACT_FIELD_TO_TYPE.policiesandAppPlugin's['policies', 'policy']entry are therefore dead pointers, in the same class as the
roles,themesanddataentries that map already documents as removed. Recorded here; not touchedin this PR.
Criterion 2 — registration order: the inference does NOT fully hold
The card infers that once the copies agree, order stops mattering. Measured in
both orders into one registry:
key differs in either order, after the fix.
ADR-0010 provenance envelope; the bundle reader does neither. So which copy
survives still depends on which reader ran last, on three axes:
sharingRule.conditionis the stringrecord.status == "open"on the bundle copy and{ dialect, source }on thedoor copy (ADR-0122 post-parse shape). A consumer reading
.condition.sourcereads
undefinedfrom one of them today — no future retired key required.active,isDefault,delegatable,allowTransfer,viewAllRecords,modifyAllRecords, present on the doorcopy only.
_packageId,_packageVersion,_provenance.Reported rather than reconciled, per the dispatch. This does not invalidate (a),
which does exactly what it was scoped to do; it says the two copies are made
consistent about conversion and are still not interchangeable. Closing
that is (b) — one route, one owner — which the card and the triage both put out
of scope, and which needs the route-ownership question answered first. The
residual is pinned key-by-key in the new suite so it turns red the day the routes
are unified.
Verification
Union run on the final commit,
3788bb5e9(clean tree):@objectstack/runtimefull suite — 198 files / 2898 tests green (249s); thenew suite is 8/8.
direction, and the e2e control goes 0 to 75 retired-key survivals. Mutation
confirmed on disk before every reading (anchor counts 1 to 0 removed / 0 to 1
injected; blob
2561f5e0to7317d84e); restored withgit checkout HEAD -- (absolute path)under anEXIT INT TERMtrap and provenby an empty
git diff HEADplus a blob hash equal to the HEAD blob. No rebuildwas needed for either leg: the mutated subject is reached from source by a
relative import inside its own package, not through
dist.@objectstack/runtimetypecheck green. NOT MEASURED, declared: the new testfile is in no tsc program —
packages/runtime/tsconfig.jsonexcludes**/*.test.tsand the package is not onboarded toscripts/check-test-typecheck.mts. Checked ad hoc with a probe tsconfig thatincludes tests: 217 pre-existing errors in that package's test layer, 0 in the
new file.
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackafter the final commit — all 24 derived plus the convention-triggered families
green at their own verdict lines, including
check:stack-collection-maps(which names
packages/runtime/src/app-plugin.tsdirectly),check:cross-package-test-inputs,check:engine-double-contract,check:where-matcher,check:type-check-coverageandcheck:type-check-debt --re-measure("none above its recorded number", after thefull 70-task package build).
pnpm lint(eslint--no-inline-config) exit 0 — no narrowing claimed.scripts/pm/check-half-states.mjsis NOT MEASURED (exit 3, PREREQUISITE NOTMET — no GitHub credential in this container); CI owns it.
The derived change set includes #12843's eight files because they arrive through
this branch's merge; they were verified on their own PR.
Generated by Claude Code
Generated by Claude Code