fix(security,verify): keep the security reference-alias read tolerant but LOUD, narrow verify with a named finding - #13541
Conversation
…verify's with a named finding Two remaining tolerant readers of the rejected `reference` aliases, given different dispositions because their failure modes differ: - plugin-security `resolveCbpRelation` KEEPS the tolerance (a miss there is fail-closed: RLS_DENY_FILTER on read, MasterDetailRelationMissingError on write) and now reports the alias once per object through the plugin's own report sink. - verify `relationTarget` narrows to canonical-only and names the cause, so an alias-spelled relation reports "this app spells a rejected alias" rather than the generic "could not be derived". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
…the changeset check:doc-authoring refuses an internal issue id inside a runtime string: it reaches operators and generated surfaces, none of which can resolve #NNNN. The tag is now a resolvable name and the ruling anchor moved to an adjacent comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
…hecks, and press the ratchet down Under moduleResolution NodeNext a relative import without .js does not resolve, so every symbol it names is any. That one bare specifier was carrying 1x TS2835 plus every TS7006 in the file — the new cases would have drifted the package's TEST_DEBT entry from 8 to 19. With the extension the whole file is clean and the entry measures 3, lowered via the gate's own --lower and re-tallied to the composition that is actually left. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
📓 Docs Drift CheckThis PR changes 2 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 15 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 fe0e70787277860109ebda17e7370bb278284950 && git checkout fe0e70787277860109ebda17e7370bb278284950
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3959a394d6e028a4fd5048f96b5498af18a9ecdf 5c467362cdd4214d278dbbc5aea32707e4f66dff && git checkout -B drift-repro 3959a394d6e028a4fd5048f96b5498af18a9ecdf && git merge --no-ff 5c467362cdd4214d278dbbc5aea32707e4f66dff
node scripts/docs-audit/affected-docs.mjs --json 3959a394d6e028a4fd5048f96b5498af18a9ecdf
|
|
PM review — ACCEPT, landing held until CI converges (22 success · 2 skipped · 6 running · 0 red at time of writing). My bar is every check green. ⛔ I do not merge it. The clause ② question is answered by MEASUREMENT, and that matters more than the answerI flagged the content limb as open at dispatch: a named verify finding might widen a published surface. It does not — and the seat measured it rather than reasoning from the fact that it compiles:
⭐ And it ran the check that settles it rather than arguing: ⭐ The refinement in Zone 2 C is the sentence worth keeping: "the shape is a free-form Both rulings honoured, including the one that forbids actingQ1 = A. It is loud through Granularity (my Zone 2 D): once per object per cache generation, inheriting Q2 = C. Narrowed to canonical-only with the named finding. The blocked text names the alias, the underived target, and the remedy; the skipped reason is The gate-script edit is a tightening, and one gate found a real defect
⭐ And Ablation was run twice — at an intermediate head and again at the final head — because either the tag rename or the import fix could have made an assertion vacuous. That is the right instinct. My docs pointer was resolved by reading, not by pattern-matchI relayed Routed — ⛔ grading is triage's#13542 (the deferred Q1 narrowing behind a stored-metadata migration — the ruling's own separate card, with the population measurement it must start from) and #13543 (a fourth tolerant reader at Declared and left alone, correctly
⇒ On full green I flip ready and enqueue. Generated by Claude Code |
Fixes #13250
The last two tolerant readers of the rejected
referencealiases, givendifferent dispositions — because their failure modes are different in kind.
Maintainer ruling, 2026-08-30, verbatim 「同意」; the ruling is recorded on the
card at comment
5468957088. The lint reader was already narrowed and mergedseparately and is not touched here.
Q1 —
plugin-security: the tolerance STAYS, and is now LOUDresolveCbpRelationstill readsreference ?? reference_to ?? referenceTo.Narrowing it is the one thing the ruling forbids, and the reason is measured
rather than stylistic:
ql.getSchema(), i.e. theSchemaRegistry, and a rawregisterObjectskips Zod by design. Re-measuredon this branch through the real registry: a raw round-trip serves the field
back as
["name","type","required","reference_to"]— canonical absent, aliasverbatim — with the canonical spelling surviving as a positive control.
resolveCbpRelationreturning null is fail-closed: the read leg answers
RLS_DENY_FILTER(zerorows for every non-admin caller) and the write leg throws
MasterDetailRelationMissingError. Narrowing would take a raw-registered,alias-spelled
controlled_by_parentobject from "access derived from itsmaster" to "everything denied, and writes throw" — an availability outage on
a population that provably exists.
So the card's defect — a tolerant consumer hiding an authoring mistake — is
answered by making the tolerance loud. When the alias is what resolved the
relation, the plugin now reports it through its own
SecurityReportSink, thesame
warnchannel and console-backed default as every other report site inthe file. No new diagnostic channel was invented.
Granularity: once per object, per cache generation. The report sits inside
resolveCbpRelation's resolution body, which runs only on acbpRelCachemiss, so 25 reads of one object produce one report — measured. It is
deliberately not per-read: this is under the per-request RLS path, and a noisy
channel is one operators filter out, which would make the tolerance silent
again by a longer route. It re-arms when the
metadata.watch('*')subscriptioninstalled in
start()clears that cache — exactly when a Studio / AI-authoringauthor is listening. A hand-rolled "already warned" set would have to reproduce
that invalidation to stay correct.
refKeywas introduced so the diagnostic and the resolution cannot disagreeabout which key answered:
refis derived from it rather than spelling the??chain twice. The fall-through is pinned, including the present-but-nullcanonical case.
Q2 —
verify: narrowed, and the finding says WHYrelationTargetnow reads the canonicalreferenceonly. The alias isreachable here too —
loadConfig()does no validation of its own ("the gatelives in the loaded module"), so it arrives through two unparsed doors.
Re-measured on this branch with isolated controls: the same fixture parses
clean with
reference, is refused by the default strict parse with"Unrecognized key(s) on this field:
reference_to", and survives verbatimthrough both
defineStack(cfg, { strict: false })and a plain-object config.Unlike Q1, this consumer's failure mode is a report line rather than a
refusal, so narrowing costs coverage and not availability. But narrowing alone
would have traded one silent seam for another, so the reason ships with it:
instead of degrading to the generic "has no
referencetarget"; an optionalrelation is skipped under
relation-rejected-reference-alias:KEYrather thanthe generic
relation-missing-reference. A field carrying both a canonicalreferenceand a stale alias derives from the canonical key and reportsnothing.
Clause 2, answered with evidence: this widens no published surface
The named finding lands in the existing free-form strings
CrudCase.blocked(typedstring) andskippedFields[].reason(typedstring).@objectstack/verifyis a published package, andCrudCaseispublished as a type from its index — but neither field is an enum, no value set
is enumerated anywhere, and no gate, doc or test pins the reason vocabulary
(the repo-wide sweep finds the reason literals only in
derive.tsitself). Thepublished union that does exist,
ObjectVerifyResult.status, is untouched: ablocked case still arrives as
status: 'skipped'. No new exportedidentifier, no changed type, no new status. The content limb does not trip.
Scope
metadata first, is out of scope by the ruling's own words and is filed
separately rather than ridden in here.
not touched by this PR.
One extra fix, declared
packages/verify/src/derive.test.tsimported./derivewithout the.jsextension. Under
moduleResolution: NodeNextthat specifier does not resolve,so every symbol it named was
any— and the package'sTEST_DEBTratchetreads this layer. The new cases would have drifted the entry from 8 raw tsc
errors to 19; with the extension the whole file is clean and the entry measures
3. Lowered with the gate's own
--lowerand thenotere-tallied to thecomposition that is actually left (three
harnesstest files with the samemissing extension), because a note describing the larger pile reads as "nearly
graduated" to the next author. That is why
scripts/check-type-check-coverage.mjsis in this diff.
Verification — at
5c467362cdDependency closures and then the whole workspace built first (70/70 turbo
tasks), so nothing below is a verdict about a stale
dist/.@objectstack/plugin-securitysuiteTest Files 90 passed (90)/Tests 1665 passed (1665)@objectstack/verifysuiteTest Files 9 passed (9)/Tests 48 passed (48)pnpm lint(whole repo,--no-inline-config)pnpm check:type-check-debt29 ledger entr(ies) re-measured … none above its recorded number·surplus: nonepnpm check:doc-authoring831 pinned site(s) … no growth, no burn-down unrecordednode packages/lint/scripts/check-reference-carrier-shape.mjscheck:*namespace — not derived, run anyway)pnpm check:verify-stand-in2 stand-in check(s) guarded … 0 asserted driver argumentspnpm check:nul-bytesscanned 7515 text file(s) … no raw ASCII control bytes39 derived gate families were run in total (30 derived from the first change
set, 9 more after the gate-script edit changed it), plus the convention-triggered
ones. Every exit code was captured before any pipe. Two gates reported
NOT MEASURED by their own refusal text rather than a finding, and neither is a
red:
check-test-completeness(PREREQUISITE NOT MET— it grades a savedturbo test log; CI tees one) and
scripts/pm/check-half-states.mjs(needs areal GitHub credential; the
--self-testform CI runs,check:pm-half-states,exits 0).
One gate found a real defect in the first draft and it was fixed as prescribed:
check:doc-authoringrefused the tracker id inside the diagnostic's runtimestring — operators cannot resolve
#NNNN— so the tag is now a resolvable nameand the anchor moved to an adjacent comment.
Ablation — three legs, each red in the predicted direction
Run at the final head, implementation committed first, every mutation proven on
disk by grepping both the injected and the deleted text, every restore proven
by a
git hash-objectmatch against the HEAD blob plus an empty whole-treegit status --porcelain, under anEXIT INT TERMtrap with an absolute reporoot. Both suites read their subject through a same-package relative specifier,
so the mutation is live in the source the run consumes without a
dist/rebuild.
vitest exit=1— 8 failed, includingreference_to resolves the relationvitest exit=1—Tests 5 failed | 6 passed (11)??chainvitest exit=1—Tests 4 failed | 8 passed (12)Baseline before each:
11 passed (11)and12 passed (12).Generated by Claude Code
Generated by Claude Code