Repository navigation
fix(runtime): AppPlugin registers the forward-converted copy of every collection - #23024
Conversation
… collection
AppPlugin handed the manifest service the RAW bundle, so the ObjectQL
registry (the code layer GET /meta/<type> lists and the email loader reads)
held items with retired keys while the artifact door's item read served the
converted copy. The boot now runs ONE ADR-0087 forward conversion over
{ ...manifest, ...collections } after the D1 handshake, memoized, and both
the manifest registration and the security block take it.
Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6
Co-authored-by: Claude <noreply@anthropic.com>
…beside a current-spelling control The list read, the item read and the execution face of a real createStandaloneStack boot over an undeclared-engines artifact, for a dashboard (dashboard-refresh-interval-to-refresh-interval-seconds) and an email template, plus a control artifact in the current spellings and three unit legs: one conversion shared by both registrars, a current bundle handed over by reference, and a packages[] bundle handed over as authored. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude <noreply@anthropic.com>
…ry copy Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f9ef3f7951b312263e75a1b08a3c723fe218d4ff && git checkout f9ef3f7951b312263e75a1b08a3c723fe218d4ff
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9432bac077ecbfa703c09409f3fed416edd41d99 0f6556d1c5c1cc53f73896f494dff140be5a0180 && git checkout -B drift-repro 9432bac077ecbfa703c09409f3fed416edd41d99 && git merge --no-ff 0f6556d1c5c1cc53f73896f494dff140be5a0180
node scripts/docs-audit/affected-docs.mjs --json 9432bac077ecbfa703c09409f3fed416edd41d99
|
…ion axis The five pins that measured the registry copy against the door's on the ADR-0087 conversion axis now pin the converged state the forward-converted registry copy produces: the grant map and RLS entry without the retired keys, the converted sharing-rule recipient and access level, and the legacy role share seeded once as a position share. The parse-axis pins are unchanged. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs: card #22994 (body, triage ① Derived judgments1. What the ObjectQL registry now holds, and every reader that moves with it — RIGHT. 2. An already-current bundle registers byte-identically — RIGHT. 3. The authorization outcome — RIGHT, and
4. One conversion per boot, ordering and source decisions — RIGHT. 5. The five rewritten pins — RIGHT. 13 6. The changeset's sentences against the diff — RIGHT, two nits. Every sentence is true of the diff: the raw-bundle cause, the one conversion after the handshake, the identity return for a current bundle, the seeders reading the registry first, the ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #22994
Clause-②: no
Door 2 only, as triage graded it. Door 1 (the conversion window in
packages/metadata-core) is #23001, untouched here.What changes
AppPlugin.init()handed themanifestservice the RAW bundle. That service decomposes it into the ObjectQL registry overMETADATA_ARRAY_KEYS. The registry is the code layer of the flattened list thatGET /api/v1/meta/TYPEserves and thatgetMetaItemsForExecutionhands to the email template loader. On the artifact boot, the door (MetadataPlugin) registered the forward-converted copy into the metadata service. So the item read served the converted copy while the list served the raw one.Now the boot runs ONE ADR-0087 forward conversion (
forwardConverted, memoized,packages/runtime/src/app-plugin.ts):assertProtocolCompatis unmoved and still first).{ ...manifest, ...collections }, the definition the security block has converted since the AppPlugin registers artifact security metadata in memory from the RAW bundle, bypassing the artifact door's ADR-0087 conversion — a second unconverted reader of the same bytes #12844 work. For a bundle withoutpackages[], that is byte for byte the payloadinit()always registered.manifest.register(...)ininit(), and the ADR-0057 security block instart(), which no longer converts on its own. No reader converts again:readFlattenedMetaItemsand the email loader are untouched.Why this input and not
this.bundle:permissionsit cannot read, at both arms, silently — the second consumer of the #18031 two-readings collision #18034 pin red. Themanifest-permissions-string-list-removedentry would stripmanifest.permissionsbefore the registrar sees it. The "2 of 2" warning would then describe a value that never arrived.permissionsit cannot read, at both arms, silently — the second consumer of the #18031 two-readings collision #18034 source decision still reads the RAW inputs.fromManifestreadsthis.collections.manifestandthis.collections[field], unchanged, because the conversion can rewrite a collection key (rolestopositions).securityMetadataRegistrar: 'artifact-door'it registers nothing and logs the same debug line.packages[]is handed over as authored, as before. Resolvingpackages[]ininit()is reserved to themanifestservice by the posture the plugin-dev finding(plugin-dev):DevPlugindegrades a malformed stack at the metadata door but the option-B readers want to refuse it — which posture is it? #15292 falsifier pins (4/4 green against the rebuiltdist). Also, no conversion reaches package bodies, at the artifact door either (measured, see Acceptance notes).Measured, before and after
The card's door:
os dev -a dist/objectstack.json --freshon an undeclared-engines artifact (objectstack-ai/objectstackat BASE6a7eca6868, then at this branch), read over HTTP.The second type is
dashboard, converted by the major-18 retired entrydashboard-refresh-interval-to-refresh-interval-seconds(retiredAfter 17.3.0).GET /api/v1/meta/dashboard, ops_boardrefreshInterval: 300,_diagnostics.valid: false(the tombstone's rename prescription)refreshIntervalSeconds: 300,valid: trueGET /api/v1/meta/dashboard/ops_boardrefreshIntervalSeconds: 300,valid: truerefreshIntervalSeconds: 300,valid: true(the same document as the list)GET /api/v1/meta/email_template, probe.welcomecategory/isSystem,valid: trueGET /api/v1/meta/email_template/probe.welcomevalid: trueOn this tree
category/isSystemare live keys: their retirement is #22990, which is parked on this card. So the email half was measured on #22990's heade4e96505e0with and without this patch, in a scratch tree, through the realcreateRegistryEmailTemplateLoaderover the booted kernel's protocol service:category: custom,isSystem: falsewith_diagnostics.valid: false;email_template.categorywas removed…".{ name: probe.welcome, locale: en-US, subject: Welcome, body_html, active: true }.The list and the item still differ on the PARSE axis: the door's copy carries the email schema's
variables: []/active: truedefaults. That is route ownership (#12892), not this conversion. The pins assert "every key the list serves equals the item's", never "one document", except for the dashboard, where the two really are one document.Tests
packages/runtime/src/app-plugin-converted-collections.test.ts:createStandaloneStackboot over the card's artifact. Four checks:valid: true;valid: true;EmailTemplateDefinitionSchema.safeParse(stripReadDecorations(item)).packages[]bundle is handed over as authored.The email legs are positive controls on this tree. Measured on #22990's head: this file is 8/8 green with the patch and 5 red without it, both email legs included. So they become live pins the day that retirement lands, with no edit.
Ablation of the one conversion line in
init(), run throughscripts/ablation-replace.mjs:39d6bb3b6958to891daa76d2b3.permissions/capabilities/sharingRulesin the ObjectQL SchemaRegistry (AppPlugin.init→manifest.register), and the plugin-security / plugin-sharing seeders read that copy FIRST #14491 pin (the BASE behaviour), at86d2782f09, before the patch round rewrote five of those pins.git diff HEADis empty.app-plugin.tsreaches the runtime suite by relative source import. The before and after probe differed without one.All on
86d2782f09:pnpm --filter @objectstack/runtime test: at0f6556d1c5, 357 of 357 files and 5141 tests pass, 19 skipped.pnpm --filter @objectstack/runtime typecheck: exit 0; the test layer held its debt ledger.tsc --listFilescompiles the new file with 0 errors in it.app-plugin-artifact-forward-conversion.test.ts,standalone-stack-security-registrar.test.ts;permissionsit cannot read, at both arms, silently — the second consumer of the #18031 two-readings collision #18034:app-plugin.manifest-security-collision.test.ts.dispatch-gates.mjsexit 0.check:dual-build-cjs-loadsfirst answered PREREQUISITE NOT MET (exit 3) and was measured green after building the 10 missingdists.--ranreconciliation: "63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED (a DERIVED zero …)".--print-configputs both in eslint's population;--format jsonreports 2 files, 0 errors, 0 warnings;parserOptionsis{ ecmaVersion, sourceType }), and the four custom rules are AST-local, so no untouched file's verdict can move.#14491's measurement: five conversion-axis pins rewritten (seat ruling A,
6122651929→ claim6121395708widened)packages/runtime/src/standalone-stack-seeder-declaration-copy.test.tsnow pins the converged conversion axis. None of the five pins is skipped, relaxed or deleted; each keeps its subject and asserts the measured converged values (commit0f6556d1c5):probe_agent: the registry grant map and RLS entry carry noallowRestore/allowPurge/priority. The three door defaults are still absent (the parse axis).sys_permission_setRLS equals the door's.share_legacy_dealscarriessharedWith.type: positionandaccessLevel: editin the registry, equal to the door's.share_legacy_levelcarriesaccessLevel: editin the registry.share_legacy_dealsis now SEEDED, exactly once, as a position share:recipient_type: position,recipient_id: sales_mgr,access_level: edit. That gives three rows where there were two. Before this PR it was dropped at seeding (roleis refused bymapRecipientType). The changeset says so.The file's header now records that #22994 closed the conversion axis, while the parse axis (string
condition, and the missingscope/isDefault/activedefaults) stays #14491's. Those pins are unchanged.Ablating the
init()conversion line turns exactly the five rewritten pins red against the raw registry copy (5 failed | 8 passed). The restore is proven against HEAD.Open PR #22962 (#15204 stage 8) edits the same file and deletes pin 2; whichever lands second merges
main. The seat declared this on #15204.Acceptance notes (not this PR)
_parseAndRegisterArtifact) refuses an artifact whose only dashboard sits inpackages[0].manifest.dashboardswithrefreshInterval, with "invalid_type at packages.0.manifest.dashboards.0.refreshInterval". The conversion walks a definition's own collections. Same family as ADR-0087 window: a^18artifact built during the v18 prerelease gets no D2 replay of an entry retired after it was built, so it fails to boot (door 1, split from #22994) #23001, for the seat to route.this.bundle/this.collectionsafter the seam, answered for assumption 5. Each reader takes one of three answers: it needs the converted copy, it keeps the raw one, or it does not care.codeDefinedDatasourceOwners, the in-memorydatasourceregistration,connectDeclared): raw. The drivers readconfig.timeoutMs/persistence.autoSaveIntervalMs, so a datasource still carrying the retiredconfig.timeout(turso) orautoSaveInterval(memory) loses the value at connect.scheduleAppArtifactJobs(ctx, this.bundle)): raw. The binder readsjob.timeoutMs, so a retiredjobs[].timeoutschedules with no timeout.datasourceMapping, objects passed toconnectDeclared(read forname/datasourceonly), seeddata: no conversion targets what they read;timeout/timeoutMs;emitCatalogEvent,grantedPermissions: no conversion touches them.loadTranslations): NOT MEASURED.registerUnclaimedTopLevel): raw, same class.Generated by Claude Code