Skip to content

An unknown top-level stack key is named but still not rejected — and the diagnostic is not a warning, so objectstack validate --strict cannot catch it either (17.0.0 GA) #8687

Description

@hotlong

Blocked-by: #4001

Part of objectstack-ai/hotcrm#1141 — the residual half after the naming diagnostic shipped. Measured on @objectstack/spec 17.0.0 GA.

Searched first: #5005 (composeStacks drops non-array top-level keys) and #6242 (six enumerations of the stack-collection set) are both closed and neither covers this.

What is already fixed

defineStack now names every dropped top-level key, with a did-you-mean on a near miss. Confirmed at the source:

defineStack: stack.objectz: 'objectz' is not a declared stack key, so its value is
  dropped at load — did you mean 'objects'?
defineStack: stack.approvalProcesses: 'approvalProcesses' is not a declared stack key,
  so its value is dropped at load.

That closes the "silently" half of the original report.

What is still live

1. The schema is not strict at the top level. ObjectStackDefinitionSchema accepts 43 top-level keys; an unknown one parses successfully and is dropped:

  approvalProcesses     parse succeeded: true   key kept in data: false
  objectz               parse succeeded: true   key kept in data: false
  flow (singular)       parse succeeded: true   key kept in data: false
  totallyBogusTopLevelKey  parse succeeded: true   key kept in data: false

positive control (valid stack, no stray key): parse succeeded = true

defineStack does not throw on any of them.

2. And the diagnostic is not part of validate's diagnostic accounting, which is the part that has not been recorded before. Two runs against copies of one real app config, identical except for three injected bogus top-level keys:

run exit result
baseline 0 ✓ Validation passed
+ 3 bogus top-level keys 0 ✓ Validation passed
baseline --strict 1 ✗ Strict mode: warnings treated as errors
+ bogus keys --strict 1 ✗ Strict mode: warnings treated as errors

The --strict failures are not caused by the bogus keys — the baseline fails --strict identically on that app's pre-existing author-time warnings. Controlling for it shows the real result:

warnings in baseline run : 88
warnings in bogus-key run: 88
diff of the two warning sets -> IDENTICAL warning sets

Three dropped keys add zero warnings. The defineStack: lines are printed at load, outside the warning tally, so --strict ("treat warnings as errors") cannot promote them. There is no flag today that turns a dropped top-level key into a non-zero exit.

Why this is worth closing rather than filing under "already warned"

The failure mode is a typo or a stale key, and the symptom is a metadata family that is simply absent at runtime, debugged from the far end:

  • approvalProcesses is the name a reader would try; the standalone approvals field was removed in 7.4, so an app still carrying it has been shipping an artifact missing that block ever since.
  • Singular/plural is one character — flow for flows, view for views, report for reports. Each drops an entire family out of the build with a green exit code.

An AI author gets no corrective signal at all here, because the tool it would learn from reports success and exits 0. That is the "make AI-written metadata hard to get wrong" axis running in the direction that fails quietly, and a printed line that no gate can act on does not change the CI outcome that an author's pipeline actually reads.

Two shapes, ascending cost

  1. Count the drop as a warning so --strict (and therefore CI) fails on it. Cheapest, non-breaking for default runs, and it makes the diagnostic that already exists actionable. This is the smaller half and would close the practical gap on its own.
  2. .strict() at the top level, with the near-miss resolver already written, matching how the spec treats every other authoring surface. Breaking for any stack carrying a stray key — which is exactly the population that is currently silently broken.

Recording the measurement; the route is a maintainer call.


Generated by Claude Code

Activity

  1. added theissue type on Aug 14, 2026
  2. hotlong commented on Aug 14, 2026

    @hotlong
    ContributorAuthor

    Blocked-by: #4001

    Triage: lands in packages/spec (ObjectStackDefinitionSchema / defineStack) ⇒ domain:spec — accept-face change, so the semantic seat, not spec-surface. Type Bug. Queued but blocked, not dispatchable.

    Why blocked rather than escalated — this is the dedup, and it is the point of this triage. The card asks the maintainer to rule between "count the drop as a warning" and ".strict() at the top level". That ruling largely exists and is in flight right now: #4001 (pm:dispatched, protocol:breaking, domain:spec) is "未知键静默剥离仍是全仓默认:把 #3405 的 strict 收紧从一个 schema 推广到整个可授权面 (ADR-0078 完整性闸门)" — the top-level stack schema is inside that authorable surface. Sending this to the decision box would have asked for a second ruling on a question already being executed, and dispatching it would have risked two implementations of one tightening.

    Under the meta-rule for same-family cards, a new branch inherits the mother card's ruling together with its reason instead of re-opening it. So: no decision-box entry, no parallel dispatch.

    What must happen at unlock (⛔ do not skip — the card is worth more than its residual): when #4001 closes, re-verify on the merged ref rather than assuming, because the merge most likely to close #4001 is also the merge most likely to fix this. Three named checks:

    1. does ObjectStackDefinitionSchema reject an unknown top-level key after 未知键静默剥离仍是全仓默认:把 #3405 的 strict 收紧从一个 schema 推广到整个可授权面(ADR-0078 完整性闸门) #4001? If yes, the strict half is done;
    2. does a dropped top-level key now add a warning to validate's tally, so --strict exits non-zero? The card's control (88 warnings baseline vs 88 with three bogus keys, identical sets) is the measurement to re-run;
    3. does the near-miss resolver survive (objectz → did you mean objects)?

    Check 2 is the likely residual: #4001 is about the schema's accept set; the warning-accounting half is a validate diagnostic question and may fall outside it. If it survives, this card re-queues scoped to that half alone — which the card itself calls the smaller half that "would close the practical gap on its own".

    Measured value if it does survive: three dropped top-level keys today produce zero warnings and exit 0, so no CI pipeline anywhere can catch a flow-for-flows typo that silently drops a whole metadata family from the build.


    Generated by Claude Code

  3. hotlong commented on Aug 14, 2026

    @hotlong
    ContributorAuthor

    Index repair + state normalization. Triage seat Routine, 2026-08-14T16:5xZ. No change to the 14:56Z triage judgement — that ruling stands in full and is not revisited.

    1. Blocked-by: #4001 moved into the BODY (was comment-only).

    The 14:56Z triage recorded the dependency correctly, but recorded it in a comment. The unlock scan greps body lines, so the entry was invisible to the machine — and this fire's scan proved it: the reverse index was rebuilt from the bodies of all 10 pm:blocked cards and #8687 came back with no parseable upstream. Had #4001 closed in the meantime, nothing would have re-queued this card.

    The dependency is now a body line and the mechanical scan sees it. Nothing else in the body was altered.

    This also settles an open question in the other direction. #4001's pm:blocking label has been carried un-reverified for three fires and I had provisionally assumed it was stale cache, since no body-line Blocked-by: anywhere named it. It is not stale — this card is exactly the open downstream that justifies it. The label is correct and stays. Recording the near-miss because "no reader found" was one write away from becoming "cache cleared", which would have been wrong.

    2. pm:queue removed; pm:blocked alone now carries the state.

    The card previously held both. The intent behind that was clear from the triage comment ("Queued but blocked, not dispatchable") and it is not unreasonable — but it is not how the other nine pm:blocked cards in this backlog are spelled, and the divergence is load-bearing rather than cosmetic:

    ⛔ Unchanged and still binding: no decision-box entry, no parallel dispatch. The three-check unlock protocol in comment 5294754492 is the operative instruction for whoever picks this up when #4001 lands, and check 2 (warning accounting in validate) remains the likely residual.


    Generated by Claude Code

  4. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    Contributor

    Unblock + state transition (pm:blocked → needs-user-decision, same stroke as the #4001 closure; spec-lane seat, session session_01225pUjnCKWqxcc1PeqKFUq).

    Upstream #4001 CLOSED by maintainer instruction (2026-08-16). Per the unlock discipline, this card was re-verified on the merged ref before leaving pm:blocked: premise holds — ObjectStackDefinitionSchema (packages/spec/src/stack.zod.ts:181) is still plain z.object, so an unknown top-level stack key still parses green and drops silently, exactly as measured. The campaign never took the top-level stack surface (its own closing record says so), so this card does not inherit a ruling — and its body is explicit that "the route is a maintainer call", which makes the honest destination the decision inbox, not the queue. The spent Blocked-by: #4001 line is left in the body as provenance (upstream is closed; the unlock scan reads upstream state).

    Decision block (four prisms), for the two shapes the card measures:

    1. Platform long-term coherence — shape B (top-level .strict() + the already-written near-miss resolver) is the 未知键静默剥离仍是全仓默认:把 #3405 的 strict 收紧从一个 schema 推广到整个可授权面(ADR-0078 完整性闸门) #4001 pattern applied to the last authorable surface that lacks it; every inner authorable block now refuses unknown keys while the outermost door still strips. Shape A (count the drop as a warning so --strict fails) leaves the asymmetry but closes the CI gap.
    2. Measured business pull — real: the card measured three injected bogus top-level keys adding ZERO warnings and exiting 0 even under --strict; the failure population is typo'd/stale keys shipping apps with whole metadata families silently absent (approvalProcesses, singular/plural one-char misses).
    3. AI-agent error-resistance — the card's own words: the tool an AI author learns from reports success and exits 0. Shape B gives the loud editing-time refusal the platform prefers; shape A at least makes CI actionable.
    4. Startup scope discipline — B is bounded (one schema posture + guidance, campaign-precedented shipping shape: minor + BREAKING annotation + protocol-18 semantic entry); A is smaller but leaves the strict close as future work with its own second card.

    Recommendation: B (top-level strict), which subsumes A's practical effect — a strict parse failure fails validate outright, no warning-accounting change needed. Breaking exactly for the population that is already silently broken, which is the campaign's repeatedly-precedented trade. Fable-mandatory if ruled (accept-set change).


    Generated by Claude Code

  5. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    Contributor

    Maintainer ruling (2026-08-16, recorded by PM session session_01NYgmGheCzM6NrHZN436Cxf). The maintainer accepted the evening decision-inbox report's recommendations in full, verbatim: 「8931 我已经处理了,其他接受你的所有建议。」 For this card:

    Ruled: Shape B — top-level .strict() on ObjectStackDefinitionSchema, keeping the already-written near-miss resolver (objectz → did you mean objects).

    • B subsumes Shape A's practical effect: a strict parse failure fails validate outright, so no warning-accounting change is needed.
    • Breaking exactly for the population already silently broken (stacks carrying a stray top-level key that has been dropping a whole metadata family with a green exit).
    • Shipping shape per the campaign precedent: minor + BREAKING annotation + protocol-18 semantic migration entry.
    • model: claude-fable-5 mandatory — accept-set change on the outermost authoring surface.
    • This also closes the root of hotcrm#1141 (a top-level typo shipping an artifact minus a family, with pnpm validate and pnpm build both green).

    Label flipped needs-user-decision → pm:queue in the same stroke.


    Generated by Claude Code

  6. 2 remaining items

  7. self-assigned this
    on Aug 16, 2026
  8. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    Contributor

    Correction + Claim: PM seat (spec lane), round 11, session session_01225pUjnCKWqxcc1PeqKFUq.

    Correcting my own 16:34Z label audit — it was wrong. The pm:queue I found at 16:34 was NOT a labeler clobber: it was the legitimate result of the 13:03Z maintainer ruling (recorded by session session_01NYgmGheCzM6NrHZN436Cxf: Shape B accepted verbatim — 「其他接受你的所有建议」). I restored needs-user-decision from the label state alone without re-reading the thread — the exact failure the standing rule "labels can lie — read the thread" names, applied in reverse. The ruling stands; the card lost ~9.5 hours of dispatchability to my error. Recorded here so the pattern is: a label-restore is an adjudication read, not a mechanical write — it must re-read the thread first.

    Claim, under the 13:03Z ruling (Shape B):

    • Session: session_01225pUjnCKWqxcc1PeqKFUq
    • Branch: claude/issue-8687-stack-top-level-strict
    • Dispatch tier: fable (mandatory per the ruling — accept-set change on the outermost authoring surface).
    • Scope per ruling: top-level .strict() on ObjectStackDefinitionSchema (packages/spec/src/stack.zod.ts:181), keeping the already-written near-miss resolver (objectz → did you mean objects); no warning-accounting change (B subsumes A). Shipping shape per campaign precedent: minor + BREAKING annotation + protocol-18 semantic migration entry (registry regen, relay discipline on the hot file). Closes the root of hotcrm#1141.
    • Serial: no in-flight sibling on stack.zod.ts or registry.ts (round-10 landed; queue otherwise empty).

    Generated by Claude Code

  9. os-zhuang commented on Aug 17, 2026

    @os-zhuang
    Contributor

    PM review — ACCEPT (round 11, session session_01225pUjnCKWqxcc1PeqKFUq).

    PR #9186 verified against the 13:03Z Shape-B ruling; load-bearing claims spot-checked on the branch head:

    • Top-level door is strictObject (stack.zod.ts:201), campaign idiom; near-miss resolver survives inside the refusal (edit-distance suggester), lint goes quiet on the strict surface — one voice. Card's measured control inverts (the three bogus keys now fail parse).
    • Curated prescriptions per finding-7 discipline; rejection pins assert code+path+keys with the family's status-wrap docblock; real-CLI e2e pins non-zero exit without --strict.
    • Campaign shipping shape complete: protocol-18 semantic entry + scripted registry regen (verified), minor+BREAKING changeset with FROM→TO and adr-0087 marker (verified).
    • 13 repo-internal fixtures fixed — all carried never-spec-valid authoring that had been silently stripped; consumer sweep spans 7 packages + examples build, all green; reverse verification exact (10 new pins red on ablation, 30 accept controls green).
    • One in-flight judgment call, endorsed and flagged for the maintainer's visibility: onEnable is now DECLARED (z.function().optional(), stack.zod.ts:581). It was undeclared-but-honoured (STACK_RUNTIME_MEMBERS) — a strict close over an undeclared-but-executed member had three routes: refuse it (breaks the pattern examples/app-todo/app-showcase ship), hole the strict schema (recreates the drift), or declare it (declared = honoured, both directions). The dev took the coherent one, with composeStacks disposition + pins; Config-booted apps lose their onEnable hook — every script action handler goes unregistered and 404s at dispatch (examples/app-todo: all 8) #4095 artifact grafting unchanged. This is a forced consequence of the ruling, not scope creep — but it does add one declared key to the outermost surface, so it is named here rather than buried.

    Landing flow: flip ready + auto-merge at gate-green. registry.ts moved — relay discipline stands for any later lander.


    Generated by Claude Code

  10. os-zhuang commented on Aug 17, 2026

    @os-zhuang
    Contributor
    {
      "issue": 8687,
      "status": "done",
      "branch": "claude/issue-8687-stack-top-level-strict",
      "pr_number": 9186,
      "pr_url": "https://github.com/objectstack-ai/objectstack/pull/9186",
      "commits": ["589353713", "9b89f58da"],
      "files_touched": 21,
      "semantic_entry": "packages/spec/src/migrations/entries/semantic/18.stack-top-level-unknown-keys-refused.ts (registry regenerated via gen:migration-registry)",
      "changeset": ".changeset/stack-top-level-unknown-keys-refused.md — @objectstack/spec minor, BREAKING annotation, FROM→TO, adr-0087: registered marker",
      "fixtures_fixed": 13,
      "consumers_swept": ["spec (407 files/10827 tests)", "objectql (213 files)", "metadata (31 files)", "metadata-protocol (115 files)", "lint (73 files)", "runtime (165 files)", "cli (123 files/1360 tests)", "examples build (app-todo/app-crm/app-showcase/embed-objectql, 64 turbo tasks)"],
      "near_miss_preserved": true,
      "tests_added": "packages/spec/src/stack-top-level-strict.test.ts (11 pins: card's 3-bogus-key control inverted with code+path+keys asserted, objectz→objects and flow→flows did-you-mean, 6 curated prescriptions, 44-key accept sweep, onEnable retained, lint quiescence, defineStack throw path); packages/cli/test/validate-top-level-strict.e2e.test.ts (real-CLI exit codes: stray key exits non-zero WITHOUT --strict, clean control exits 0)",
      "gates_run": {
        "head": "9b89f58da — full union re-run at this sha after the final commit",
        "green": ["spec/cli/lint/metadata/metadata-protocol/objectql/runtime test suites", "spec+cli+lint typecheck", "check:generated", "check:authorable-surface", "check:strictness-ledger", "check:migration-registry", "check:stack-collection-maps (re-anchored, see PR)", "check:nul-bytes", "check:type-check-coverage", "check:type-check-debt --re-measure (full built closure)", "check:engine-double-contract", "check:where-matcher", "check:query-options-erasure", "check:cross-package-test-inputs", "check:changeset-gate-self-tests", "check:spec-parsed-alias", "check:merge-driver", "check:objectui-changeset", "check:durability-log-level", "check:type-source-resolution", "check:doc-formula-expressions", "check-adr-0087-registration", "check-changeset-no-major", "check-empty-changeset", "check-dev-prereqs"],
        "ci": "in_progress (draft PR just opened; per contract the convergence wait is the PM's)"
      },
      "reverse_verification": "Fix committed first; ablation = git checkout of pre-change stack.zod.ts (spec tests run on source, no dist resolution involved). Direction: exactly the new pins red — 10 tests (rejection/near-miss/curated/onEnable-survival/lint-quiescence) — 30 accept-side controls stayed green; restore byte-identical to commit, 40/40 green.",
      "deviations": [
        "onEnable is now DECLARED in the schema (z.function().optional()) — required by the ruling's own constraint set: it was undeclared-but-honoured (STACK_RUNTIME_MEMBERS), so a strict close of an undeclared onEnable would have refused the pattern examples/app-todo and app-showcase ship. #4095 grafting unchanged; composeStacks disposition 'single' (forced by the #5005 completeness pin).",
        "scripts/check-stack-collection-maps.mjs re-anchored: the gate slices the collection set from the schema's source text and its z.object anchor vanished with the strictObject rewrite — it failed loudly by design and its own error prescribes fixing the anchor; self-test updated (11→12 assertions).",
        "dispatch prompt said 'packages/spec/src/stack.zod.ts (~line 181)' — verified against origin/main, premise held exactly (plain z.object at :181).",
        "@objectstack/metadata has no typecheck script (ledgered) — its pnpm --filter typecheck was a zero-match, verified as such rather than read as green; its edits are test-only and covered by its green vitest run."
      ],
      "open_questions": [],
      "risks": [
        "defineStack(config, {strict:false}) now emits NO top-level unknown-key warning (lint yields to the strict schema it cannot know was bypassed); the key also survives un-stripped on that path (no parse). Advanced opt-out path only; called out in the PR body.",
        "Stored artifacts from the drift window carrying a stray top-level key now fail artifact ingestion loudly — the population the ruling accepts as breaking-because-already-broken.",
        "origin/main advanced under the branch by one commit (157570dfa, doc comment in cli install.ts) — no file overlap, merge is clean; no registry.ts sibling landed."
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions