Skip to content

feat(lint): cross validateRetiredPermissionResidue onto the runtime authoring door (permission writes, advisory) - #18722

Merged
os-bill merged 3 commits into
mainfrom
claude/issue-17936-residue-rule-runtime-door
Sep 17, 2026
Merged

os-bill merged 3 commits into
mainfrom
claude/issue-17936-residue-rule-runtime-door

Conversation

@os-bill

@os-bill os-bill commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Fixes #17936
Clause-②: no

Out of #17425's class-one ruling D. validateRetiredPermissionResidue now runs at the runtime authoring door on permission writes, at advisory tier, so the population ruling D named — an author who writes permission metadata as JSON through Studio / REST /meta / MCP and never runs os lint — gets the same signal at the only door they have.

The tension this card turned on, and how it was settled

The rule's registry entry was CLI_ONLY with a surfaceReason that did not say "never", it said "not measured":

Crossing it needs a measurement this round did not take — whether the gate's body reaches it BEFORE the per-type safeParse, whose residue stage strips the only evidence this rule reads. Post-parse the rule is structurally silent, so wiring it there without that reading would publish a phantom check, not coverage.

The card asserted the crossing was feasible. Both could be true at once, so the first act of this PR was to take that measurement rather than assume either side.

Measured: the residue does reach the rule. At packages/metadata-protocol/src/protocol.ts, saveMetaItem runs the per-type safeParse and then deliberately keeps the authored body — parsed.data would strip the Studio-only auxiliary fields an overlay rides with — grafting back exactly two normalizations (graftNormalizedOperators for filter operator spellings, graftFoldedFormSections for the form groups to sections key move). Each is a walk over the authored keys that adds nothing and removes nothing else. So assertRuntimeAuthoringRules is handed the raw document; evaluateRuntimeAuthoringGate passes it straight through as item: args.body; and buildRuntimeWriteSnapshots puts that same object into candidate.permissions, which is exactly what the rule reads. input: 'normalized' therefore stays load-bearing rather than becoming a contradiction — the gate's snapshot is an unparsed body.

That is not argued in the PR text alone: a pin at the door reads the persisted row back and asserts allowRestore: false survived the parse, and it is green both before and after the registration change, because it measures the door's body handling and not the crossing.

What changed

  • packages/lint/src/authoring-rules.ts — the residue entry moves to surfaces: CLI_AND_RUNTIME with runtimeTypes: ['permission']. tier: 'advisory', input: 'normalized' and commands: ALL are untouched.
  • The stale surfaceReason is removed rather than reworded. That field answers "why the runtime gate does NOT run this rule"; once it does, no wording of it is true. The reasoning moves into the entry's comment, beside the measurement that settled it.
  • ⛔ The rule body (validate-retired-permission-residue.ts) is not touched — this card changes which surfaces it is registered for.
  • ⛔ No accept-set change, and never a refusal: an advisory rides the 2xx the write earns. A value that is not the retired default (true, 0, null) is still refused by the tombstone at the parse, with its prescription attached, exactly as before.
  • permission is the only declared type, because stack.permissions is the only collection the rule reads.
  • Pins: packages/lint/src/runtime-gate.permission-residue.test.ts (dispatch, wire path, the baseline/candidate differential, the CLI-door control) and a #17936 block in packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts (the door, end to end through saveMetaItem).

Evidence

Red before green — ablation, with on-disk proof and a hash-verified restore

Run from the committed implementation (670efa9cbe), mutating the crossing back out.

leg reading
pre-state working-tree blob dc8588c9… equals the HEAD blob for authoring-rules.ts
mutation on disk runtimeTypes: ['permission'], occurrences 1 to 0; surfaces: CLI_ONLY, occurrences 27 to 28; git diff --stat 1 file, +1 / -2
reached the artifact rebuild, then ablation-dist-preflight … --absent: "marker absent from all 14 built files"
ABLATED lint suite 6 failed / 4 passed (10)
ABLATED door suite 3 failed / 32 passed (35) — the LIT arm reads advisories: undefined
restore git checkout HEAD -- …; blob back to dc8588c9…, git diff HEAD empty
reached the artifact again rebuild, then preflight (present): marker in 4 built files (index.js, index.cjs, runtime.js, runtime.cjs); tree clean against HEAD
RESTORED lint suite 10 passed (10)
RESTORED door suite 35 passed (35)

Direction was as predicted: red, not "more diagnostics" and not a reversal.

LIT control — the advisory must actually fire

  • At the gate: runRuntimeAuthoringRules({ type: 'permission', item }) on a set carrying allowRestore: false returns errors: [] and advisories whose rule list is exactly ['permission-retired-lifecycle-residue'] — severity warning, path permissions.sales_team.objects.crm_ticket.allowRestore (the finding: a publish refusal's issues[].path carries an array index into the gate's private snapshot (objects[417].sharingModel) that no caller can resolve #10064 name-keyed wire shape), hint non-empty because it resolves from the tombstone's own description.
  • At the door: saveMetaItem({ type: 'permission', … }) returns success: true, the row lands, and advisories[] carries the same six-key entry with path permissions.sales_team.objects.leave_request.allowRestore. Both keys present advises twice.
  • Ablated, both read zero. That is what makes the green a measurement.

DARK controls — both read 0, as readings and not as expectations

  • A clean write advises nothing. At the door, 'advisories' in result is false — the whole key is absent, not an empty array. At the gate, result.advisories deep-equals []. ⚠️ The fixtures author readScope on purpose: without it the pre-existing security-private-no-readscope info advisory rides every one of these writes and the reading would be "one instead of two" rather than a true zero. This arm is green in both the ablated and the restored run — it reads 0 either way.
  • The CLI door is unchanged. Both CLI-door cases are among the 4 that pass in the ablated run as well as the restored one. Their readings: on each of validate / build / lint, exactly 1 finding, path permissions[0].objects.crm_ticket.allowRestore (positional — the name-keying did not leak onto the commands), severity warning, message and hint byte-identical to the rule's own direct output; on a clean stack, 0 findings on each of the three.
  • No other metadata type reaches the rule: every runtime-gated type other than permission is asserted not to dispatch it.
  • A stored set's residue is not charged to this write: with a residue-carrying sibling in context.permissions, a clean write reads 0 and a dirty one still reports exactly its own one path ([runtime/metadata] 作者时规则只存在于 CLI:Studio/REST/MCP 的运行时授权面是第四扇门,26 条规则一条不跑——#4409 修完后最大的敞口 #4463 D4's differential, non-vacuous in both directions).

Suites and gates, at 3bee0e438e

  • pnpm --filter @objectstack/lint test104 files, 3882 tests, all passed.
  • pnpm --filter @objectstack/metadata-protocol test178 passed / 3 skipped (181 files), 2554 passed / 19 skipped.
  • pnpm --filter @objectstack/lint typecheck (which also runs check:test-typecheck) and pnpm --filter @objectstack/metadata-protocol typecheck — both exit 0.
  • Gate families derived from the real change set with scripts/pm/dispatch-gates.mjs (never a hand list) and reconciled with --ran: 59 derived, 56 run green, 0 UNRUN, 3 NOT MEASURED. The three are check:dual-build-cjs-loads, check:lean-entry-closure and check:type-check-debt, each exiting 3 — PREREQUISITE NOT MET: they read built output for the whole monorepo, which this worktree does not have. Exit 3 is neither a pass nor a finding; CI builds the closure and measures them.
  • pnpm lint (eslint . --no-inline-config) — the whole-repo union, not a narrowing — exit 0, re-run at the final commit 3bee0e438e.

Acceptance notes

Noted, not filed:

  • A crossed rule can still carry a stale surfaceReason. authoring-rule-wiring.test.ts requires a substantive surfaceReason when surfaces omits runtime-publish, but nothing refuses one that is still present when it does not — so a "why the gate does not run this" sentence can outlive the crossing it contradicts. Removed by hand here and pinned for this entry in the new lint test. Not a reproducible defect, a declared-contract violation or a metadata-authoring trap, so it is not a card. Who meets it next: the seat that crosses the next CLI_ONLY rule — sibling card cross validateRetiredPermissionResidue to the runtime publish door — the CLI door cannot reach the AI/Studio JSON authors ruling D named #17944 (the PUBLISH door) is the nearest named candidate.
  • The security-private-no-readscope info advisory already rides permission writes that grant allowRead on a private object with no readScope. Pre-existing and correct; recorded here only because it is why the new fixtures author readScope, and because it is the reason a "no advisories at all" reading has to be built rather than assumed.
  • _advisoryWarned dedupes per process, keyed type|name|rule|path, so a second test reusing one permission-set name reads zero log lines from a gate that is working exactly as designed. By design, not a defect; the new operator-log case carries its own name and says why in place.

For the reviewing seat: this diff adds a key to an AUTHORING_RULES registry entry, which the clause-② C5 widening tell can match on shape. It is not a contract widening — no schema key, no closed-set member, no new published export, no accept-set change, and the added judgement can only advise, never refuse. The card and the claim both declare Clause-②: no.

⛔ Not in this PR: the PUBLISH-door half of the same file (#17944, still pm:queue and unclaimed) is not addressed here and remains open.


Generated by Claude Code

…horing door (RED)

The pins for the #17936 crossing, landed before the registration edit so the
red/green pair is readable: the LIT arm (a permission write carrying
`allowRestore` / `allowPurge` produces the advisory and still publishes) fails
today because the rule is registered CLI_ONLY, while the DARK arms (a clean
write advises nothing, the CLI door is unchanged) already read 0.

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <[email protected]>
…e authoring door

The rule was registered CLI_ONLY on a stated open question: does the runtime
gate's `body` reach it BEFORE the per-type `safeParse`, whose residue stage
strips the only evidence it reads? Measured: it does. `saveMetaItem` keeps the
AUTHORED body verbatim by design and grafts back exactly two normalizations,
each a walk over the authored keys, so `assertRuntimeAuthoringRules` is handed
the raw document and the gate passes it straight through as `item`.

So the crossing is coverage, not a phantom check, and the population ruling D
on #17425 named — a Studio / REST `/meta` / MCP author who never runs `os lint`
— now gets the same signal at the only door they have. Advisory tier: it rides
the 2xx the write earns and can never refuse one. `permission` is the only
declared type, because `stack.permissions` is the only collection it reads.

The `surfaceReason` recording why the gate did NOT run it is removed rather
than reworded: the question it held open has an answer now, and the reasoning
lives in the entry's comment beside the measurement that settled it.

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <[email protected]>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 3 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/validating-metadata.mdx (via AUTHORING_RULES (symbol, a top-level const object), runtimeTypes (symbol, a field of const object AUTHORING_RULES))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 4 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 df1b275c71fb7939f085c013779f3daefa5114c7packageMentionDocs.

Which tree this was computed on

This run read content/docs from 48093328b0f7f5421d364bb161d7b3ac2d035bf4 — the merge of head 3bee0e438ec64825e380a533edfeb17b8f82ed06 into base df1b275c71fb7939f085c013779f3daefa5114c7, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 48093328b0f7f5421d364bb161d7b3ac2d035bf4 && git checkout 48093328b0f7f5421d364bb161d7b3ac2d035bf4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin df1b275c71fb7939f085c013779f3daefa5114c7 3bee0e438ec64825e380a533edfeb17b8f82ed06 && git checkout -B drift-repro df1b275c71fb7939f085c013779f3daefa5114c7 && git merge --no-ff 3bee0e438ec64825e380a533edfeb17b8f82ed06

node scripts/docs-audit/affected-docs.mjs --json df1b275c71fb7939f085c013779f3daefa5114c7

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs df1b275c71fb7939f085c013779f3daefa5114c7 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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

Labels

Projects

None yet

2 participants