Skip to content

fix(runtime): AppPlugin's bundle path consumes the artifact door's ADR-0087 forward conversion - #12878

Merged
os-litant merged 10 commits into
mainfrom
claude/issue-12844-app-plugin-artifact-conversion
Aug 28, 2026
Merged

fix(runtime): AppPlugin's bundle path consumes the artifact door's ADR-0087 forward conversion#12878
os-litant merged 10 commits into
mainfrom
claude/issue-12844-app-plugin-artifact-conversion

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes #12844

⚠️ STACKED — do not land before #12843

applyArtifactForwardConversions does not exist on origin/main. The card
states it is "now exported from @objectstack/metadata-core"; that is true only
on the branch of #12843, which is open and unmerged. This branch therefore
carries a merge of PR #12843 at head 7eff8966 so the change could be written
and, 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:

The change

AppPlugin's ADR-0057 security block registered the bundle from
loadArtifactBundle raw. It now applies the door's own policy function to the
same 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 roles to positions entry rewrites a
collection key, not an item, so a projection would silently miss it.

The conversion notices are logged at debug, not surfaced: on an artifact boot
the 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(), over
packages/metadata/src/__fixtures__/hotcrm-17.1-built-permissions.artifact.json
(the manifest and permissions blocks verbatim from the artifact released
@objectstack/cli 17.1.0 built, 75 allowRestore + 75 allowPurge occurrences,
engines.protocol ^17.1.0):

permission sets registered object grants inspected grants still carrying a retired key
before (fix ablated) 6 75 75
after 6 75 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
AppPlugin second — the ordinary artifact-boot order — the raw copy wins
outright
, 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.

collection conversions reaching it divergence before after
permissions 2 (permission-allow-restore-purge-removed, permission-rls-priority-removed) content — bundle copy keeps allowRestore: true, allowPurge, rowLevelSecurity[0].priority: 10; door copy has them stripped agree
sharingRules 2 (sharing-rule-access-level-full-to-edit, sharing-recipient-role-to-position) content — bundle copy keeps accessLevel: 'full' and recipient type: 'role'; door copy has 'edit' / 'position'. Neither raw value is in the current enums, so that copy is unparseable at any re-validating seam agree
positions 1 (stack-roles-to-positions, a COLLECTION-key rename) presence — bundle reader looks for positions on bytes spelling the collection roles and registers nothing; door registers the position agree
capabilities 0 none constructible — one reader only. capabilities is an authorable stack collection (ADR-0066 D1) that ARTIFACT_FIELD_TO_TYPE in packages/metadata/src/plugin.ts does not map, so the door registers nothing under capability and AppPlugin is the sole registrar n/a
policies 0 none constructible — not an authorable collection. ObjectStackDefinitionSchema is a strictObject with no top-level policies key (on the permission set, policies is an alias for rowLevelSecurity). A top-level policies collection is refused outright, so it reaches neither registry n/a

Both ARTIFACT_FIELD_TO_TYPE.policies and AppPlugin's ['policies', 'policy']
entry are therefore dead pointers, in the same class as the roles, themes and
data entries that map already documents as removed. Recorded here; not touched
in 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:

  • On the ADR-0087 conversion axis the inference holds: no conversion-governed
    key differs in either order, after the fix.
  • On every other axis it does not. The door also strict-parses and stamps the
    ADR-0010 provenance envelope; the bundle reader does neither. So which copy
    survives still depends on which reader ran last, on three axes:
    1. Schema transforms. sharingRule.condition is the string
      record.status == "open" on the bundle copy and { dialect, source } on the
      door copy (ADR-0122 post-parse shape). A consumer reading .condition.source
      reads undefined from one of them today
      — no future retired key required.
    2. Schema defaultsactive, isDefault, delegatable,
      allowTransfer, viewAllRecords, modifyAllRecords, present on the door
      copy only.
    3. ADR-0010 provenance_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/runtime full suite — 198 files / 2898 tests green (249s); the
    new suite is 8/8.
  • Ablation of the one call site (fix reverted): 5 of 8 red, in the predicted
    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 2561f5e0 to 7317d84e); restored with
    git checkout HEAD -- (absolute path) under an EXIT INT TERM trap and proven
    by an empty git diff HEAD plus a blob hash equal to the HEAD blob. No rebuild
    was needed for either leg: the mutated subject is reached from source by a
    relative import inside its own package, not through dist.
  • @objectstack/runtime typecheck green. NOT MEASURED, declared: the new test
    file is in no tsc program — packages/runtime/tsconfig.json excludes
    **/*.test.ts and the package is not onboarded to
    scripts/check-test-typecheck.mts. Checked ad hoc with a probe tsconfig that
    includes tests: 217 pre-existing errors in that package's test layer, 0 in the
    new file
    .
  • Gate families derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
    after 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.ts directly),
    check:cross-package-test-inputs, check:engine-double-contract,
    check:where-matcher, check:type-check-coverage and
    check:type-check-debt --re-measure ("none above its recorded number", after the
    full 70-task package build).
  • Full-repo pnpm lint (eslint --no-inline-config) exit 0 — no narrowing claimed.
  • scripts/pm/check-half-states.mjs is NOT MEASURED (exit 3, PREREQUISITE NOT
    MET — 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

claude added 8 commits August 28, 2026 01:59
…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
…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
…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
@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing 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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2b4178aa53ca62089f43e2cfae0b7838cf340dd1packageMentionDocs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 28, 2026
@github-actions github-actions Bot added size/m and removed size/xl labels Aug 28, 2026
@os-litant
os-litant marked this pull request as ready for review August 28, 2026 07:09
@os-litant
os-litant added this pull request to the merge queue Aug 28, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 28, 2026
Merged via the queue into main with commit 39d625f Aug 28, 2026
33 checks passed
@os-litant
os-litant deleted the claude/issue-12844-app-plugin-artifact-conversion branch August 28, 2026 13:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants