feat(lint): cross validateRetiredPermissionResidue onto the runtime authoring door (permission writes, advisory) - #18722
Conversation
…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]>
Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <[email protected]>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 4 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 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
|
Fixes #17936
Clause-②: no
Out of #17425's class-one ruling D.
validateRetiredPermissionResiduenow runs at the runtime authoring door onpermissionwrites, at advisory tier, so the population ruling D named — an author who writes permission metadata as JSON through Studio / REST/meta/ MCP and never runsos 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_ONLYwith asurfaceReasonthat did not say "never", it said "not measured":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,saveMetaItemruns the per-typesafeParseand then deliberately keeps the authored body —parsed.datawould strip the Studio-only auxiliary fields an overlay rides with — grafting back exactly two normalizations (graftNormalizedOperatorsfor filteroperatorspellings,graftFoldedFormSectionsfor the formgroupstosectionskey move). Each is a walk over the authored keys that adds nothing and removes nothing else. SoassertRuntimeAuthoringRulesis handed the raw document;evaluateRuntimeAuthoringGatepasses it straight through asitem: args.body; andbuildRuntimeWriteSnapshotsputs that same object intocandidate.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: falsesurvived 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 tosurfaces: CLI_AND_RUNTIMEwithruntimeTypes: ['permission'].tier: 'advisory',input: 'normalized'andcommands: ALLare untouched.surfaceReasonis 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.validate-retired-permission-residue.ts) is not touched — this card changes which surfaces it is registered for.true,0,null) is still refused by the tombstone at the parse, with its prescription attached, exactly as before.permissionis the only declared type, becausestack.permissionsis the only collection the rule reads.packages/lint/src/runtime-gate.permission-residue.test.ts(dispatch, wire path, the baseline/candidate differential, the CLI-door control) and a#17936block inpackages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts(the door, end to end throughsaveMetaItem).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.dc8588c9…equals theHEADblob forauthoring-rules.tsruntimeTypes: ['permission'],occurrences 1 to 0;surfaces: CLI_ONLY,occurrences 27 to 28;git diff --stat1 file, +1 / -2ablation-dist-preflight … --absent: "marker absent from all 14 built files"advisories: undefinedgit checkout HEAD -- …; blob back todc8588c9…,git diff HEADemptyindex.js,index.cjs,runtime.js,runtime.cjs); tree clean against HEADDirection was as predicted: red, not "more diagnostics" and not a reversal.
LIT control — the advisory must actually fire
runRuntimeAuthoringRules({ type: 'permission', item })on a set carryingallowRestore: falsereturnserrors: []andadvisorieswhose rule list is exactly['permission-retired-lifecycle-residue']— severitywarning, pathpermissions.sales_team.objects.crm_ticket.allowRestore(the finding: a publish refusal'sissues[].pathcarries 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.saveMetaItem({ type: 'permission', … })returnssuccess: true, the row lands, andadvisories[]carries the same six-key entry with pathpermissions.sales_team.objects.leave_request.allowRestore. Both keys present advises twice.DARK controls — both read 0, as readings and not as expectations
'advisories' in resultisfalse— the whole key is absent, not an empty array. At the gate,result.advisoriesdeep-equals[].readScopeon purpose: without it the pre-existingsecurity-private-no-readscopeinfo 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.validate/build/lint, exactly 1 finding, pathpermissions[0].objects.crm_ticket.allowRestore(positional — the name-keying did not leak onto the commands), severitywarning, message and hint byte-identical to the rule's own direct output; on a clean stack, 0 findings on each of the three.permissionis asserted not to dispatch it.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
3bee0e438epnpm --filter @objectstack/lint test— 104 files, 3882 tests, all passed.pnpm --filter @objectstack/metadata-protocol test— 178 passed / 3 skipped (181 files), 2554 passed / 19 skipped.pnpm --filter @objectstack/lint typecheck(which also runscheck:test-typecheck) andpnpm --filter @objectstack/metadata-protocol typecheck— both exit 0.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 arecheck:dual-build-cjs-loads,check:lean-entry-closureandcheck: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 commit3bee0e438e.Acceptance notes
Noted, not filed:
surfaceReason.authoring-rule-wiring.test.tsrequires a substantivesurfaceReasonwhensurfacesomitsruntime-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 nextCLI_ONLYrule — 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.security-private-no-readscopeinfo advisory already rides permission writes that grantallowReadon a private object with noreadScope. Pre-existing and correct; recorded here only because it is why the new fixtures authorreadScope, and because it is the reason a "no advisories at all" reading has to be built rather than assumed._advisoryWarneddedupes per process, keyedtype|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_RULESregistry 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 declareClause-②: no.⛔ Not in this PR: the PUBLISH-door half of the same file (#17944, still
pm:queueand unclaimed) is not addressed here and remains open.Generated by Claude Code