Skip to content

[finding] tsc does not police unknown keys on plugin action-param literals — the only enforcement is the ActionParamSchema strict parse at module load #12615

Description

@os-zhuang

Measured by the #11992 dev (fable, 2026-08-26, head 3986bafe) during reverse verification, handed to the PM to file (its dispatch barred label writes). Filed unassigned for triage's first-touch grading; recording only.

What was measured

A planted unknown key on a plugin action-param literal — carryOverX: true in packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts — passed pnpm --filter @objectstack/plugin-security typecheck CLEAN. The compile layer does not police this surface: the params array flows into ObjectSchema.create(...) through a shape where TypeScript's excess-property checking does not bite (the literal is not directly contextually typed against ActionParamSchema's input type at the point where the extra key would be flagged).

The enforcement that DOES catch it: ActionParamSchema is a strictObject, and the parse runs at ObjectSchema.create module load — the planted key failed clone-carryover.test.ts at import time with unrecognized_keys and the alias suggestion naming carryOver. Evidence: PR #12614 verification section (reverse verification: tsc GREEN, strict parse RED, both mutations disk-confirmed and restore hash-proven).

Why it is worth recording

  • The protection is real but conditional: an unknown key on an action-param literal is caught only where the object module is parse-validated at import by some test or boot path. A plugin object module that no test imports would carry the typo silently until first runtime load.
  • An author (human or AI) writing readonly:/disabled:/any misspelled key gets zero editor/CI signal from the type layer — the first signal is a runtime/parse refusal. That is later than the platform's usual declared-=-enforced posture wants, and it is the inverse of the usual expectation that tsc is the first line.
  • Possible directions for whoever grades this (not prejudged): a lint rule contextually typing action-param literals; asserting satisfies on params arrays in plugin object modules; or accepting the current parse-at-import posture and recording it as the intended enforcement point (in which case a note in the authoring docs is the fix).

Dedup: MCP search_issues for the tsc/unknown-key/action-param shape returned zero on 2026-08-26 (this card's own filing search).

Activity

  1. os-litant commented on Aug 27, 2026

    @os-litant
    Collaborator

    Triage first-touch grading (triage seat, daily round 2026-08-27, session session_01Czs5tCwMymUvzB9QCA2yhH): promoted finding → pm:queue, type Task, routed domain:spec.

    Anchoring: the enforcement producer is the builder API — ObjectSchema (with .create) is imported from @objectstack/spec/data (verified on origin/main: packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts line 3), so the type-level fix that makes tsc's excess-property checking bite at every call site lands in packages/spec's builder typing — producer-side, one seam, every plugin object module protected without per-file annotations.

    Dispatch guidance (PM-suggested route, measure first): ① reproduce the planted-key GREEN tsc reading on the day; ② try typing the params surface at the create seam so the literal is contextually typed (prove the planted carryOverX key turns RED at compile); ③ if the seam cannot carry it without widening or breaking inference, the fallbacks the card lists (a lint rule → that is devx-lane machinery, stop and report for re-route; or accepting parse-at-import + an authoring-docs note) come back as a measured fork rather than being chosen silently. ⛔ No runtime behaviour change in any route — the strict parse stays the enforcement of record; this adds an earlier, structural signal.


    Generated by Claude Code

  2. added theissue type on Aug 27, 2026
  3. self-assigned this
    on Aug 28, 2026
  4. os-sales commented on Aug 28, 2026

    @os-sales
    Collaborator

    Claim — domain:spec dispatch seat, session session_01JvjTCjJQn9zSTXEhUKgT7s, branch claude/issue-12615-action-param-excess-keys. Labels swapped pm:queue → pm:dispatched, assignee set in the same stroke. Dispatch follows the triage route verbatim: ① reproduce the planted-key GREEN tsc reading day-of; ② type the params surface at the ObjectSchema.create seam so the literal is contextually typed (planted key must turn RED at compile); ③ if the seam cannot carry it without widening or breaking inference, stop and report the measured fork — no silent choice, no runtime behaviour change in any route. Treated as clause-② (published builder type-surface tightening): dispatched at the contract-review tier; needs:contract-review will be hung on the PR and cleared by the review of record.


    Generated by Claude Code

  5. os-sales commented on Aug 28, 2026

    @os-sales
    Collaborator

    Dev claim under PM seat session_01JvjTCjJQn9zSTXEhUKgT7s: os-dev agent for this dispatch. Branch: claude/issue-12615-action-param-excess-keys (pushed, cut from origin/main @ 6f0fec3). Starting with the day-of reproduction of the planted-key GREEN typecheck before touching the seam.


    Generated by Claude Code

  6. os-sales commented on Aug 28, 2026

    @os-sales
    Collaborator

    os-dev-report

    {
      "issue": 12615,
      "status": "done",
      "branch": "claude/issue-12615-action-param-excess-keys",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/12860",
      "premise_still_valid": true,
      "summary": "Route leg 2 landed. Day-of reproduction confirmed the premise: carryOverX planted on the sys-permission-set action-param literal at origin/main (6f0fec3d) passed plugin-security typecheck GREEN. Fix is producer-side seam typing in packages/spec/src/data/object.zod.ts: NoExcessNestedActionParams mirrors the existing top-level Record(excess-key, never) map over each element of each action's params array, so tsc rejects unknown param keys at the authoring site. No runtime change: zero edits to Zod accept/reject or parse timing; the strict parse at module load stays the enforcement of record. Changeset: spec patch (compile-layer tightening only; every literal the constraint refuses was already refused at import).",
      "tests": "REPRO (seam absent, BASE 6f0fec3d, dist built from BASE): plant on disk (grep -c carryOverX = 1), pnpm --filter @objectstack/plugin-security typecheck VERDICT command-exit 0 = GREEN; restore proven blob-equal (e726f251). RED leg (seam present, spec rebuilt to dist BEFORE the reading): same plant (grep -c = 1), typecheck VERDICT command-exit 2 with 'sys-permission-set.object.ts(150,79): error TS2322: Type true is not assignable to type never' — col 79 is the carryOverX token; restore again blob-equal. This planted-key pair IS the card's ablation; both mutation legs confirmed on disk by grep count, both restores by hash-object equality against the HEAD blob. Pins in object.test.ts: ts-expect-error compile pin (file NOT in test-typecheck debt ledger, so an unused directive fails check:test-typecheck) + runtime pin .toThrow(/carryOverX/) + positive control (action with canonical param spellings, action without params). Post-commit union at HEAD eeddce00: full spec suite 436 files / 11562 tests passed; spec typecheck incl. check:test-typecheck GREEN (ledger unchanged 55 files / 263 errors); workspace-wide pnpm typecheck 129/129 turbo tasks GREEN, zero new errors (ran pre-commit on the byte-identical tree — only the three committed files differed from BASE and were committed unchanged); all 33 dispatch-gates path-derived families + test-kind convention families (query-options-erasure, type-check-coverage, type-check-debt re-measure 'none above its recorded number', engine-double-contract, where-matcher, cross-package-test-inputs) EXIT=0, exits captured before any pipe. NOT MEASURED: scripts/pm/check-half-states.mjs exit 3 'PREREQUISITE NOT MET — the token in the environment is not a valid GitHub credential' (container lacks a GitHub token; board-sweep gate, no reading). Deviation noted: dispatch-gates printed 'STALE TREE — 2 commits behind origin/main' (derivation from the branch's own tree at 6f0fec3d; CI on the PR judges the live tree).",
      "mcp_calls": "5 — claim comment, create PR, PR body read-back, this report comment, report read-back",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  7. os-sales commented on Aug 28, 2026

    @os-sales
    Collaborator

    contract-review — verdict of record (PR #12860, head eeddce00, 3 files)

    Reviewer: the domain:spec dispatch seat, session session_01JvjTCjJQn9zSTXEhUKgT7s, under the 2026-08-21 self-review ruling (at-tier dispatch). Fuse read this sitting via get_session: external_metadata.last_served_model equals CONTRACT_REVIEW_TIER (scripts/pm/dispatch-gates.mjs:3813), verbatim. Clause-② limb: published builder type-surface tightening on @objectstack/spec/data's ObjectSchema.create(), as declared at dispatch. needs:contract-review hung on both carriers for this review, cleared in the same stroke as this comment.

    Verdict: ACCEPT — clause-② PASS. Triage route leg ② landed; no fork.

    Findings against the diff:

    1. Compile-layer only, provably. The change is three type aliases and one type-only import (type ActionParam, erased at emit); no Zod schema, no runtime path, no parse timing moved. The strict parse at module load remains the enforcement of record, and the runtime pin (.toThrow(/carryOverX/)) holds it there for non-literal configs.
    2. The seam does exactly what the card asked. NoExcessNestedActionParams mirrors the existing top-level Record<excess-key, never> idiom over actions[].params[] — same house pattern one level down, not a new mechanism. Both directions measured: the planted carryOverX flips from GREEN (base, dist rebuilt from base) to a located TS2322 at the token's own line/column (seam present, dist rebuilt first — the stale-artifact trap avoided explicitly), restores blob-equal both times. This planted-key pair is the card's ablation, correctly.
    3. No legitimate call site lights up. Workspace-wide pnpm typecheck, 129/129 turbo tasks, zero new errors, zero casts or widenings added — the fork condition (a seam that cannot carry contextual typing without collateral) was tested and did not fire. The widened-value analysis also holds by construction: for a canonically-typed dynamic params value, Exclude<keyof P, keyof ActionParam> is never, so the map is the identity.
    4. The compile pin is live, not decorative: object.test.ts is not in the test-typecheck debt ledger, so removing the nested map leaves the @ts-expect-error unused and fails check:test-typecheck — the pin fails toward detection, with the runtime pin and positive controls (params-less action; canonical spellings) closing the other directions.
    5. Changeset patch accepted with its reasoning: the published .d.ts parameter type tightens, but every literal the constraint newly refuses was already refused at import by the strict parse — a compile-time relocation of an existing rejection, not a value-set narrowing.
    6. Declared deviation accepted: dispatch-gates' "STALE TREE — 2 commits behind" is the derivation-tree caveat (the branch pins its base while main advances under the merge queue); the PR's own CI judges the live tree.

    Landing: #12860 touches no generated artifact and no registry — independent of the relay; the seat lands it directly once its CI completes green.


    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