fix(objectql,cli): os validate refuses a view container whose name disagrees with its object, as boot does (#20331) - #20391
Conversation
…er name check (#20331) The divergent view-container `name` refusal moves out of `ObjectQL.registerMetadataCollections` into `viewContainerNameRefusal` (`@objectstack/objectql`), unchanged: the boot loop throws what it returns. `os validate` calls the same function over the views the load path registers and reports the refusal in the boot registrar's words, exiting 1 on a stack it used to pass while `os serve` refused it. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
…timeout Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
…lidate-view-container-name
…comments Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 37 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 bd7c31d2030e1309cbc59659997359bc1624dc26 && git checkout bd7c31d2030e1309cbc59659997359bc1624dc26
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ab6fb02763e1d73e7045741717a07d1294e4e83c 6b5895b978310cf4e11f437cd6947e49d38b4760 && git checkout -B drift-repro ab6fb02763e1d73e7045741717a07d1294e4e83c && git merge --no-ff 6b5895b978310cf4e11f437cd6947e49d38b4760
node scripts/docs-audit/affected-docs.mjs --json ab6fb02763e1d73e7045741717a07d1294e4e83c
|
Contract reviewServed-tier: ① Derived judgments
② Semver levelWrong as declared. ③ Boundary flags
Implemented-by: Independence: INDEPENDENT AGENT (fed the card, the triage direction and the PR only; not the dispatch order or the seat's conclusions) VERDICT: FAIL
Generated by Claude Code |
…lidate-view-container-name
…ontainer judge; fixtures boot accepts viewContainerNameRefusal now answers undefined for a container whose derived object key is falsy, the entry registerMetadataCollections warns about and skips before the name check, so os validate cannot refuse what boot skips. Pinned by a control that boot registers nothing and throws nothing. Every CLI test fixture whose views: container carried a name other than its bound object (two red integration pins among them) now names the object, so each is a stack the server loads. The changeset grades @objectstack/objectql minor with Clause-② yes for its two new root exports, and the gate-parity remedy text names VALIDATE_ONLY_GATES. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Delta review of patch round 1 (PR #20391, card #20331). Round-0 record ① Derived judgmentsRound-0 findings, each resolved:
The whole net diff, accept-set and public-surface changes named:
② Semver levelCorrect as declared. ③ Boundary flags
Implemented-by: Independence: INDEPENDENT AGENT (fed the card with every comment, the round-0 record, the REWORK, the PR body, file list and net diff, and the head's check-runs; not the dispatch order and not the dispatching seat's conclusions) VERDICT: PASS |
Fixes #20331
Clause-②: yes
os validatenow runs the same view-containernamecheck the boot registrar runs, and reports the refusal in the boot registrar's own words. Before this change it passed a stack at exit 0, andos servethen refused that same stack at boot. The triage direction was "one judge, not a second rule". The check moved out ofObjectQL.registerMetadataCollectionsinto one function, and boot andos validateboth call it. There is no lint twin and no new error code, and boot's message and envelope are unchanged.Premise, measured on
origin/main862b6ce86before any editThe setup: the CLI's dependency closure was built, then
os init my-app -t app --no-install,os g object order_lineandos g view order_line. The view'snamewas then hand-edited to'order_line', bound to objectmy_app_order_line.os validate✓ Validation passed,UI: 1 Viewsos serve --devviews:container from manifest 'com.example.my-app': the container's ownnameis 'order_line', which disagrees with the object key it binds to, 'my_app_order_line' …"os compile{ name: 'order_line', object: 'my_app_order_line' }os serve, booting that artifact (dist/objectstack.json, no config)What changed
@objectstack/objectql:viewContainerNameRefusal(container, sourceLabel, ownerId)(newsrc/view-container-name-refusal.ts) returns the refusal the boot registrar throws, orundefined. It carries the same gate: the container branch (isAggregatedViewContainer), and anamethat is present and differs from the derived key. It also carries boot's precondition for that gate: a falsy derived key is boot's warn-and-skip, and the function answersundefinedfor it. It uses the same message and the sameVALIDATION_ERROR/ 400 envelope. It derives the key itself withderiveViewContainerObject, which for a container is exactly whatresolveMetadataItemNamereturns. So no caller re-spells "which derivation boot uses".registerMetadataCollectionskeeps itskey === 'views'narrowing and throws what the function returns. Nothing else inengine.tsmoved.@objectstack/objectqlroot entry:viewContainerNameRefusaland its typeViewContainerNameRefusal. This is the widening behindClause-②: yes:@objectstack/objectqlis gradedminor, and@objectstack/clistayspatch. The changeset states it.@objectstack/cli:findViewContainerNameRefusals(parsed)(newsrc/utils/view-container-names.ts) owns the WALK only, and the verdict is objectql's. It walks what the load path registers. With nopackages[], that is the top-levelviews, under the manifest id:AppPluginhands{ ...manifest, ...stack }to the manifest service, and the id is read throughartifactPackageId. Withpackages[], it is eachpackages[i].manifest.views, under that package's id, and not the top level, whichresolveArtifactPackageOrdernever registers.os validatestep 2c runs right after the schema parse. It exits 1. The text face prints the refusals as bullets.--jsonemitserrors: [{ path, code, httpStatus, message }]and carrieswarnings/conversionslike every other exit.test/validate-build-gate-parity.test.tsgains aVALIDATE_ONLY_GATESledger. The closed roster had no honest place for a gate wired intovalidate.tsalone:SHARED_NON_REGISTRY_GATESasserts that both commands call it, and aNOT_A_GATErow would be a false statement. The new row is pruned both ways: it goes stale ifvalidate.tsstops calling the gate, and it must move toSHARED_NON_REGISTRY_GATESifcompile.tsstarts calling it.After the fix, on the same project (CLI from source,
@objectstack/objectqldist rebuilt)os validateexits 1 with✗ The server would refuse this stack at boot (1 view container)and the boot message.os validate --jsonexits 1 witherrors[0] = { path: 'views[0]', code: 'VALIDATION_ERROR', httpStatus: 400, message }. Themessageis byte-equal to the lineos serve --devprinted before the fix (diffis empty).name: 'my_app_order_line'gives exit 0, and deletingnamegives exit 0.os serve --devstill exits 1. Its message is byte-identical before and after the refactor (diffof the two boot logs is empty).Tests
packages/objectql/src/view-container-name-refusal.test.ts(new, 9 tests, with the falsy-derived-key control added in patch round 1): the refusal and its envelope. The manifest seam AND the nested-plugin seam throw exactly what the function returns. There are five controls (noname, a matchingname, a name-only container, a non-container, an empty-stringname), each also registered by boot.packages/cli/src/utils/view-container-names.test.ts(new, unit tier, 5 tests): the walk. Every row's message equals the judge's answer and is never re-spelled. Thepackages[]bodies are judged under their own ids, and the top level is not judged whenpackages[]is present.packages/cli/test/validate-view-container-name.test.ts(new, integration tier: it spawns the CLI and constructsObjectQL, 5 tests):--jsonexits 1 anderrors[0].messageequals whatObjectQL.registerAppthrows for the same stack. The text face exits 1 with the same words. The matching control and the no-namecontrol both exit 0. A premise case asserts that boot refuses the divergent stack and accepts both controls.view-container-divergent-name-registrars.test.tsare unchanged and green.4e15d3cea:@objectstack/objectql:vitest run --project local, four shards: 323 files, 5865 tests passed. Typecheck (tsc --noEmit×2 pluscheck:test-typecheck) exit 0.@objectstack/cli:vitest run --project unit, two shards: 232 files, 3315 tests passed. Typecheck exit 0.--project integrationwas run locally for the new file only (5/5); the rest of the integration tier is declared to CI.tsc --listFilesOnlyconfirms every new file is in a typecheck program (tsconfig.json/tsconfig.test.jsonof each package).pnpm lint(fulleslint . --no-inline-config) exit 0.Ablation (fix committed first; mutation through
scripts/ablation-replace.mjs)return undefinedat the top ofviewContainerNameRefusal, marker__ABLATION_20331_NEVER_REFUSE. The tool reported anchor x1 to x0 and blob07da29b9toab52f626. The objectql build andablation-dist-preflight.mjsfound the marker in 4 built files.data.namediffers from the derived key — one silently rewrites the author's field, the other hard-fails the whole artifact load #14666): the boot loop REFUSES the divergent container …" and "MEASURED CORRECTION … refuses, enveloped (MetadataFacadeanswers threeregister→getround-trip cases differently from every other shippedIMetadataService#7378 row 1)".--json) and the text face. The controls stayed green.git checkout HEAD --on the absolute path, by the tool's trap. The blob after the restore equals HEAD07da29b9, andgit diff HEADis empty. After the rebuild,ablation-dist-preflight --absentfound the marker absent from all 14 built files and a working tree clean against HEAD. objectql was then 20/20 green and cli 10/10 green.Gates at
4e15d3ceaThese were derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, which gave 69 commands.--ranreconciled them as 68 run, 1 NOT-MEASURED and 0 UNRUN. Every command exited 0 except the one below:check:type-check-debt --re-measureruns a whole-workspaceturbo run buildinside itself. On this shared box that build did not fit the foreground cap: the first attempt was SIGTERMed mid-build, and I rebuiltpackages/specafterwards. Neither touched package carries aDEBT/TEST_DEBTrow.lint.ymlruns it in CI.Six gates first answered "prerequisite not met" or asked for deeper history, and each was re-run after its prerequisite was met:
check:dual-build-cjs-loads,check:i18n,check:i18n-coverageandcheck:i18n-walk-parityexited 3 until the CLI, the examples and the unbuilt packages were built, then 0.check-engine-split-ratio.mjs --days 90exited 2 until the history was deepened with--shallow-since, then 0.check-issue-citations.mjsexited 2 on four added citations that no longer resolve. Those were rephrased, and it then exited 0 with and without--base origin/main.Patch round 1 (head
6b5895b97, after the FAIL review of4e15d3cea)test/union-fold-command-parity.test.ts(PEDIGREE CONTROL) andtest/validate-per-package-authoring-parity.test.tsasserted exit 0 on stacks the boot registrar refuses: each carried containers named*_listbound to*_account/*_order. Each container now names its bound object, and each pin's assertion is unchanged.packages/cli/test/,packages/cli/src/,packages/qa/andexamples/(all 963 tracked.ts/.js/.jsonfiles, the.e2etier included). A structural scan read every object literal carryingnameplus a container arm (list/form/listViews/formViews) and noviewKind, and derived the key as boot does. It found 15 divergent containers in 9 files, all fixed the same way: the 4 above,build-text-face-advisory-count.test.ts(2),format-zod-union.test.ts(1),info-detail-package-fold.test.ts(1),lint-handwritten-checks-package-fold.test.ts(1),lint-label-case-localized.test.ts(2),lint-per-package-authoring-parity.test.ts(2),lint-per-package-authoring-seam.test.ts(2). A rescan finds none, apart from the docblock examples in this PR's own files.examples/:os validateexits 0 on each of app-crm, app-multi-package, app-showcase and app-todo, with 0 container refusals.packages/qa/: no hit.viewsis not a map-form collection (MAP_SUPPORTED_FIELDSexcludes it), so no key-injected name can hide there.@objectstack/objectqlis nowminorwithClause-②: yes, for the two new root exports (viewContainerNameRefusal,ViewContainerNameRefusal).@objectstack/clistayspatch. The changeset states the widening.registerMetadataCollectionswarns about and skips an entry whose derived key is falsy before the name check runs.viewContainerNameRefusalnow returnsundefinedfor that entry too. The derivation keeps''forlist: { data: { object: '' } }, so without thisos validatewould refuse a container that boot only skips. A new control pins it: boot throws nothing and registers nothing, and the function returnsundefined. The docblock and theengine.tscomment now say what moved: the message and the envelope moved byte for byte, and the gate moved whole, precondition included.VALIDATE_ONLY_GATEStoo.Runs at
6b5895b97:@objectstack/objectql:--project localin 4 shards, 325 files / 6017 tests passed. Typecheck exit 0. The new test file is 9/9.@objectstack/cli:--project unitin 2 shards, 233 files / 3335 tests passed.--project integrationIN FULL in 3 shards, 61 files / 512 tests passed and 1 skipped. Typecheck exit 0.OS_TEST_TIERS=nightly: the census touched no.e2efile, so the 13 nightly files that runos validatewere run instead. 13 files / 129 tests passed.pnpm lintexit 0.check-issue-citations --base origin/mainexit 0.check-changeset-no-majorlevel axis: driven with--eventon this body withClause-②: yes, it answers "✓ LEVEL AXIS …@objectstack/objectql: minor" (exit 0).--ranreconciles 69 run, 0 NOT-MEASURED, 0 UNRUN, every one exit 0. That includescheck:type-check-debt: 4 entries re-measured, none above its number.scripts/ablation-replace.mjs --deleteremoved theif (!itemName) return undefined;line (anchor x1 to x0, blob21af64e5to56d25d71). Exactly the new control went red: the function returned "Invalidviews:container … binds to, ''" whereundefinedwas expected. The other 20 tests stayed green, the existing boot pins among them. The restore gave a blob equal to HEAD21af64e5and an emptygit diff HEAD, then 21/21 green.Acceptance notes
os compile/os builddoes NOT reach the shared function. It is not the same code path as validate, and under this card's scope I did not widen to it. It still exits 0 on this stack and writes an artifact thatos serverefuses when it boots it, as measured above. TheVALIDATE_ONLY_GATESrow records that gap. I reported it to the seat as an out-of-scope finding.MetadataPlugin._parseAndRegisterArtifact(packages/metadata/src/plugin.ts, the container branch around :1103-:1121) registers the container underderiveViewContainerObject(item)throughthis.manager.register('view', viewObject, item). The refusal there comes from the GENERIC register contractassertMetadataRegisterContract(packages/core/src/metadata-service-contract.ts,MetadataFacadeanswers threeregister→getround-trip cases differently from every other shippedIMetadataService#7378 row 1), in its own words ("IMetadataService.register('view', …): data.name is …"). So both registrars enforce the same rule through different mechanisms, and boot's prose is its own on purpose.packages/metadata/**is untouched.ViewSchema.nameisz.string().optional(), so an empty-string containernameis spec-valid. The boot loop (andos validate, which mirrors it) treats''as absent and registers the container. The generic register contract at the artifact door refuses it, because'' !== undefinedand'' !== key. No producer ofname: ''was found.plugins[]entry'sviewsis registered by boot under the labelnested plugin, butos validatedoes not walk it. The stack schema typespluginsasunknown[], and in an authored config they are runtime plugin instances.origin/mainwas merged atb1cbd9277, and again at0d7ed5a37in patch round 1. It has since moved 6 commits. One of them, fix(objectql)!: having resolves placeholders through the where resolver, and the per-aggregation filter refusals name aggregations[i].filter (#20334) #20368, touchesengine.tsin theengine.aggregatehunks only, and agit merge-treeprobe merges cleanly, so I did not merge again.packages/spec/**andpackages/metadata/**are unchanged. The diff adds no lint rule and no error code, and it does not change boot's message or envelope.Generated by Claude Code