Skip to content

refactor(security): one permission fold — the enforcement door and the access matrix ASK the spec helper (#18785) - #19512

Merged
huangyiirene merged 3 commits into
mainfrom
claude/issue-18785-one-permission-fold
Sep 21, 2026
Merged

huangyiirene merged 3 commits into
mainfrom
claude/issue-18785-one-permission-fold

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes #18785

Clause-②: no

One rule — "does this effective object permission grant this verb?" — had three implementations. It now has one definition in @objectstack/spec (objectPermissionGrants) and two consumers that ask it: the enforcement door PermissionEvaluator.checkObjectPermission, and the access-matrix snapshot buildAccessMatrix.

⛔ The differential ran FIRST — nothing was converged before it

The card's own "Not measured" section named this as the cheap thing to do first, and it forked the rest of the work. It was run against the three implementations as sources, before any edit.

Input enumeration, derived rather than hand-listed. The bit set is read out of the spec's own EffectiveObjectPermissionSchema.shape — every key whose unwrapped inner type is boolean — which yields exactly the eight declared bits: allowCreate, allowDelete, allowEdit, allowExport, allowRead, allowTransfer, modifyAllRecords, viewAllRecords. The verb targets come from the spec's own OBJECT_PERMISSION_VERBS value range (six). Each bit takes three states — true, false, absent — because every implementation compares with === true and absence is the state an author reaches by omission. That is 3^8 = 6561 inputs, the full cartesian product over the declared bits, not a sample.

bits (derived)  : allowCreate, allowDelete, allowEdit, allowExport,
                  allowRead, allowTransfer, modifyAllRecords, viewAllRecords  [8]
bit states      : true | false | absent  [3]
verb targets    : allowCreate, allowDelete, allowEdit, allowExport,
                  allowRead, allowTransfer  [6]
inputs          : 3^8 = 6561
cells  spec-vs-evaluator : 39366
cells  spec-vs-matrix    : 26244  (buildAccessMatrix models 4 of 6 verbs)
absent-entry leg         : 6 targets, all false = true
DISAGREEMENTS spec-vs-evaluator : 0
DISAGREEMENTS spec-vs-matrix    : 0
VERDICT differential-disagreements=0

⇒ All three agreed on every input. This is therefore a pure structural convergence and no behaviour changes — which is also why no ruling was needed. Had any cell disagreed, the card said stop, and it would have stopped.

The control — the instrument can tell the two cases apart. "They all returned the same thing" proves nothing unless the harness can see a disagreement, so the same run was repeated twice with exactly ONE cell of the spec fold perturbed, once in each direction:

control mutation what it changes disagreements found
create-bypass "Modify All Data manufactures create" (spec says true where the consumers say false) 2916 (1458 evaluator + 1458 matrix)
read-narrow read stops bypassing on modifyAllRecords (spec says false where the consumers say true) 1944 (972 evaluator + 972 matrix)

Both directions, both consumers, non-zero. Plus a second exhaustiveness leg the enumeration cannot express: objectPermissionGrants(undefined, verb) is false for all six targets, never a throw.

The evaluator's operation map is checked, not assumed. checkObjectPermission is keyed on ObjectQL operations, not on allow* bits, and its OPERATION_TO_PERMISSION is module-private. The harness's inverse map is asserted against the exported crudBucketForOperation before a single cell is compared, so a drifted mapping aborts the run instead of quietly measuring the wrong pairs.

The fold as the card records it, verified against each implementation rather than trusted: read bypasses on viewAllRecords || modifyAllRecords; write bypasses on modifyAllRecords alone and never create; export is grant ∧ read. All three held, on all three sides.

What changed

packages/plugins/plugin-security/src/permission-evaluator.ts — the per-set loop's three inline checks (the modify-all write bypass, the read bypass, the bare bit) collapse to one objectPermissionGrants(objPerm, permKey) call. OPERATION_TO_PERMISSION is retyped Record(string, ObjectPermissionVerbTarget), which is what makes that call type-safe and forces a future operation's bit to be a verb the spec actually models.

packages/lint/src/build-access-matrix.ts — the four CRUD columns become objectPermissionGrants(entry, verb). The two super-user columns stay raw bits on purpose: they report what the set declares, which is the context a reviewer reads the CRUD columns against, and folding them would destroy that.

Two things deliberately NOT collapsed, both documented at the site:

  • The export door keeps its cross-set shape. checkObjectPermission('export', …) asks (∃ set granting export) ∧ (∃ set granting read) across the whole resolved set list — the same answer the /me/permissions most-permissive per-object merge hands the client, and the same value the spec cell returns once that merge has happened. Folding it per set would have narrowed the door to ∃ set (export ∧ read), denying a caller whose read and export grants arrive from two different sets. A narrowing at the enforcement door is exactly what this card forbids. A pin was added for it.
  • MODIFY_ALL_WRITE_KEYS is kept and exported, as an assertion rather than a decision. It no longer decides anything at runtime; it still states, derived from this file's own dispatch vocabulary, which bits the write-bypass class contains, and the new test holds the spec's fold to it. Deleting it would have thrown away the [security][立项位] M2 权限生命周期(undelete/purge)功能与 allowRestore/allowPurge RBAC 同批建设(evaluator 已 fail-closed,allowTransfer 已 enforced) #1883 derivation (a future destructive op added to the map+set must make the spec go red, not silently lose its bypass) and dangled the prose in four other files that name it.

packages/lint/src/index.ts was not touched — buildAccessMatrix was already exported, and the change is an import, not an export edit.

Following #17469, and where this departs

#17469's landing is the model: driver-sql's raw field.multiple reads became one call onto the spec's isMultiValueField, behind a small documented adapter (isMultiValuedColumn) whose docblock names the ruling and says in one line that there is ONE definition.

Followed — one definition in the spec, consumers ask it, and each call site carries a docblock that says what restating it would cost rather than just what it does.

Departed, three ways, each on purpose:

  1. No local adapter. driver-sql isJsonField and spec isMultiValueField disagree for master_detail / tree / text + multiple: true, so a consumer using the spec predicate to shape a query gets a 400 #17469 needed one because its callers had already applied their own type resolution and two spellings of that default is how its drift started. These consumers hold the effective entry itself, so the call is direct and an adapter would be a third spelling with nothing to reconcile.
  2. No behaviour change and no ruling. driver-sql isJsonField and spec isMultiValueField disagree for master_detail / tree / text + multiple: true, so a consumer using the spec predicate to shape a query gets a 400 #17469 converged a live divergence, so a maintainer had to say which predicate was authoritative. Here the differential above proved agreement before anything moved, so there was nothing to rule on — and per the card, if it had disagreed this PR would not exist.
  3. No authoring-entrance half. driver-sql isJsonField and spec isMultiValueField disagree for master_detail / tree / text + multiple: true, so a consumer using the spec predicate to shape a query gets a 400 #17469 also tightened FieldSchema to refuse the flag it had been accepting. Nothing here needed a spec edit, and the dispatch is explicit that one would be a different lane.

Ablation — each consumer proved to be ASKING, not agreeing by coincidence

The pins were written independently of the spec helper, from the rule itself. Asserting consumer === objectPermissionGrants(...) would have been a tautology: both sides move together and the pin survives any change to the fold, including a wrong one. Because they restate the rule instead, one changed cell in the spec reddens them.

One scripts/ablation-replace.mjs process: mutate → rebuild spec → prove the mutation reached dist/ → run each consumer → restore, with the restore proven on disk.

ablation-replace: anchor  "case 'allowEdit': return permission.allowEdit === true || modifyAll;" x1 -> x0
ablation-replace: blob    0e6d6902b063 -> ba220b986417
ablation-dist-preflight: marker present in 2 built files -- the ablation is live in the artifact the suite consumes
  hit packages/spec/dist/security/index.js
  hit packages/spec/dist/security/index.mjs

LINT-CONSUMER      exit=1   Test Files 1 failed (1)   Tests 2 failed | 6 passed (8)
SECURITY-CONSUMER  exit=1   Test Files 1 failed (1)   Tests 3 failed | 2 passed (5)

ablation-replace: ok restored: blob == HEAD (0e6d6902b063) and `git diff HEAD` is empty
ablation-dist-preflight --absent: marker absent from all 216 built files
ablation-dist-preflight --absent: tree clean against HEAD -- nothing of this ablation is recorded outside dist/
git status --porcelain (whole tree) : empty
post-restore rerun : lint 8 passed (8) · plugin-security 5 passed (5)

The ablation cell was allowEdit's write bypass — one cell, and both consumers went red through it. Named failures:

  • lint: named cells: the fold as the rule states it, exhaustively matches the fold over every declared bit combination
  • plugin-security: the same two, plus the Modify-All write-bypass CLASS is exactly what this file derives from its own dispatch map

⚠️ The restore leg was rebuilt as well as reverted: a marker left in dist/ keeps mutated code live for every later run in the worktree, and --absent is what proves it is gone.

The enforcement door is security-relevant — every covering test file, before and after

checkObjectPermission is exercised by exactly five test files, all in @objectstack/plugin-security. Three other files name it in prose only and call nothing — packages/spec/src/security/permission.test.ts, packages/formula/src/permission-predicate.test.ts, packages/runtime/src/domains/share-links-enforcement-context.test.ts — so they are listed for completeness, not counted.

test file(s) before after
the four pre-existing door files, run as one vitest invocation — admin-export-wildcard.test.ts, export-permission-axis.test.ts, member-default-explicit-allow.test.ts, security-plugin.test.ts 334 passed 334 passed
plugin-security/src/permission-evaluator.test.ts — NEW, the file the door never had — 5 passed
door total 334 339
lint/src/build-access-matrix.test.ts (the matrix consumer) 5 passed 8 passed

"Before" is a measurement, not an inference: the two source files were reverted to the merge base and the new test file removed, the four door files were run against that tree, and the revert was undone with git checkout HEAD -- and proved byte-exact (git status --porcelain empty, git diff HEAD empty, and each of the four paths' git hash-object equal to its blob at HEAD). Nothing was removed or weakened — every pre-existing case is still there and still green.

Verification

Measured at a5e7c92e8, base 4045781fa.

what result
pnpm --filter @objectstack/lint run test 4022 passed (4022), 106 files
pnpm --filter @objectstack/plugin-security run test 2249 passed (2249), 117 files
pnpm --filter @objectstack/lint run typecheck exit 0 (incl. test layer: 2 files / 6 errors / 2 pinned signatures held, unchanged)
pnpm --filter @objectstack/plugin-security run typecheck exit 0 (incl. test layer: 0 / 0 / 0)
pnpm lint (repo-wide eslint, the union — not a narrowing) exit 0

Gates. Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, every exit code captured before any pipe, reconciled with --ran:

Run reconciliation — 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN.
✓ dispatch-gates --ran: 63 derived famil(ies) accounted for — 63 run,
  0 NOT-MEASURED (a DERIVED zero — all 63 recorded an exit code and none of them is 3).

Three of them first answered exit 3 — PREREQUISITE NOT MET, which is neither a pass nor a fail. All three named the same prerequisite — no built output on disk — and it was discharged, not reported around:

gate prerequisite it named after turbo run build over all packages
pnpm check:dual-build-cjs-loads 53 packages have no dist/ exit 0
pnpm check:i18n the CLI plus the build closure of 9 extract-config packages exit 0
pnpm check:type-check-debt 14 workspace deps of ledgered packages have no built type entry point exit 0

The derivation was re-run after a fresh git fetch origin main (which moved origin/main 4 commits): the family list came back byte-identical, so nothing new was owed.

Changeset — measured, not assumed

skip-changeset does not apply. Both packages publish, and the changed bytes reach dist:

private files[] changed bytes reach dist?
@objectstack/lint unset (public) dist, README.md, CHANGELOG.md yes — src/build-access-matrix.ts is built into dist/index.js / .cjs
@objectstack/plugin-security unset (public) dist, README.md, CHANGELOG.md yes — src/permission-evaluator.ts likewise

⇒ .changeset/18785-one-permission-fold.md, patch for both. Not breaking: no export is removed or renamed, no authorable key changes meaning, and the differential is the evidence that no answer moves. @objectstack/spec/security's ./security subpath carries both an import and a require condition, so the new value import is safe on the CJS side of both dual builds — check:dual-build-cjs-loads confirms it against real emitted bytes.

Acceptance notes

⛔ No cards filed from here — the seat files everything.

① A fourth site reads the same bits, and its own docblock claims to mirror this door — measured, they differ on one bit. The card's second "Not measured" bullet asked whether any consumer outside these three folds the same bits a fourth time. It does. packages/lint/src/validate-security-posture.ts has grantsObjectAccess, whose docblock says verbatim:

Any of the four CRUD bits, or a super-user bypass (View/Modify All Data), counts — this mirrors the runtime checkObjectPermission gate (ADR-0066 D2): that gate returns true if ANY set contributes one of these for the object.

Its disjunction omits allowTransfer. Reproduced today, on this branch:

evaluator: checkObjectPermission('transfer', 'crm_line') with objects.crm_line = { allowTransfer: true }  ->  true
lint      : security-master-detail-ungranted  —  'detail object "crm_line" … has no object-level CRUD
            grant in any permission set'

allowTransfer is authorable and enforced today through the insert/update owner_id door, so the shape is reachable. ⚠️ Whether this is a defect turns on whether "object-level CRUD" is meant to include the transfer bit — that is a ruling about the lint rule's intent, ⛔ not a refactor, and the docblock's mirror claim is the part that is measurably false either way. Proposed class (b) — a declared claim the measurement contradicts. Seam: spec:allowTransfer → runtime:packages/lint/src/validate-security-posture.ts#grantsObjectAccess. Severity is advisory only: the rule emits warning and does not gate the build. ⛔ Not touched here — outside this card's file surface, and that exact file is held by open PR #19486. Carrier: PR #19486, which already edits validate-security-posture.ts. Dedupe words: grantsObjectAccess · allowTransfer · security-master-detail-ungranted · validate-security-posture · object-level CRUD.

② The wildcard-super-user question is stated twice, and both agree — noted, not filed. resolveObjectPermission (plugin-security) and wildcardGrantsSuperRead (packages/plugins/plugin-hono-server/src/current-user-endpoints.ts) both decide "does the '*' entry carry the super-user read bypass". That is a different question from this card's verb fold, the hono-server copy already documents itself as the single reading for its own file and cites the evaluator, and they agree today. Recorded as the boundary of what this card converged, ⛔ not widened into it. Carrier: none (承接者:无).

③ Three sites in permission-evaluator.ts still read viewAllRecords || modifyAllRecords inline, and correctly so. getEffectiveScope and getDeclaredScope answer the depth axis, and superuserBypassSets answers "is the bypass bit held" — already the one function every bypass consumer folds through, per #4647. None of them answers "does this entry grant this verb", so none was converged. Stated so the next reader does not mistake the omission for an oversight. Carrier: none (承接者:无).

④ A stale code quotation in a file outside this surface — noted, not filed. packages/spec/src/security/permission.test.ts line 1172 quotes the evaluator's now-removed inline expression permKey === 'allowRead' && … in a comment. Prose drift only; the assertions around it are unaffected and still green. ⛔ Not a defect class. Carrier: none (承接者:无).

⑤ On the recorded affinity with #18783 — the dispatch asked to say so if I think they are one card. I do not. Triage's reading holds up from inside the work: this card needed a measurement and could be finished the moment the differential came back zero; #18783 is waiting on a decision. Merging them would have parked a finished measurement behind an open question. #18783 remains open and untouched. ⛔ Merging cards is triage's call in any case.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/plugin-security, touching 9 documentable anchor(s).

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

  • content/docs/automation/flows.mdx (via allowRead (literal, a string literal in buildAccessMatrix; a string literal in checkObjectPermission))
  • content/docs/concepts/architecture.mdx (via allowCreate (literal, a string literal in buildAccessMatrix), allowDelete (literal, a string literal in buildAccessMatrix), allowEdit (literal, a string literal in buildAccessMatrix), allowRead (literal, a string literal in buildAccessMatrix; a string literal in checkObjectPermission))
  • content/docs/concepts/index.mdx (via allowEdit (literal, a string literal in buildAccessMatrix), allowRead (literal, a string literal in buildAccessMatrix; a string literal in checkObjectPermission))
  • content/docs/getting-started/common-patterns.mdx (via allowCreate (literal, a string literal in buildAccessMatrix), allowDelete (literal, a string literal in buildAccessMatrix), allowEdit (literal, a string literal in buildAccessMatrix), allowRead (literal, a string literal in buildAccessMatrix; a string literal in checkObjectPermission))
  • content/docs/permissions/access-recipes.mdx (via allowCreate (literal, a string literal in buildAccessMatrix), allowEdit (literal, a string literal in buildAccessMatrix), allowRead (literal, a string literal in buildAccessMatrix; a string literal in checkObjectPermission))
  • content/docs/permissions/authorization.mdx (via buildAccessMatrix (symbol, a top-level function), checkObjectPermission (symbol, a method of class PermissionEvaluator), allowRead (literal, a string literal in buildAccessMatrix; a string literal in checkObjectPermission))
  • content/docs/permissions/delegated-administration.mdx (via allowCreate (literal, a string literal in buildAccessMatrix), allowDelete (literal, a string literal in buildAccessMatrix), allowEdit (literal, a string literal in buildAccessMatrix), allowRead (literal, a string literal in buildAccessMatrix; a string literal in checkObjectPermission))
  • content/docs/permissions/index.mdx (via allowCreate (literal, a string literal in buildAccessMatrix), allowDelete (literal, a string literal in buildAccessMatrix), allowEdit (literal, a string literal in buildAccessMatrix), allowRead (literal, a string literal in buildAccessMatrix; a string literal in checkObjectPermission))
  • content/docs/permissions/permission-metadata.mdx (via allowCreate (literal, a string literal in buildAccessMatrix), allowDelete (literal, a string literal in buildAccessMatrix), allowEdit (literal, a string literal in buildAccessMatrix), allowRead (literal, a string literal in buildAccessMatrix; a string literal in checkObjectPermission))
  • content/docs/permissions/permission-sets.mdx (via allowCreate (literal, a string literal in buildAccessMatrix), allowDelete (literal, a string literal in buildAccessMatrix), allowEdit (literal, a string literal in buildAccessMatrix), allowExport (literal, a string literal in a comment in checkObjectPermission), allowRead (literal, a string literal in buildAccessMatrix; a string literal in checkObjectPermission))
  • content/docs/permissions/permissions-matrix.mdx (via allowCreate (literal, a string literal in buildAccessMatrix), allowDelete (literal, a string literal in buildAccessMatrix), allowEdit (literal, a string literal in buildAccessMatrix), allowExport (literal, a string literal in a comment in checkObjectPermission), allowRead (literal, a string literal in buildAccessMatrix; a string literal in checkObjectPermission))
  • content/docs/permissions/rls.mdx (via allowEdit (literal, a string literal in buildAccessMatrix), allowRead (literal, a string literal in buildAccessMatrix; a string literal in checkObjectPermission))
  • content/docs/protocol/kernel/index.mdx (via allowRead (literal, a string literal in buildAccessMatrix; a string literal in checkObjectPermission))
  • content/docs/protocol/objectql/security.mdx (via allowCreate (literal, a string literal in buildAccessMatrix), allowDelete (literal, a string literal in buildAccessMatrix), allowEdit (literal, a string literal in buildAccessMatrix), allowRead (literal, a string literal in buildAccessMatrix; a string literal in checkObjectPermission))
  • content/docs/ui/forms.mdx (via allowCreate (literal, a string literal in buildAccessMatrix))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v13.mdx (via buildAccessMatrix (symbol, a top-level function))
  • content/docs/releases/v14.mdx (via allowDelete (literal, a string literal in buildAccessMatrix))
  • content/docs/releases/v17/17-0.mdx (via allowExport (literal, a string literal in a comment in checkObjectPermission), allowRead (literal, a string literal in buildAccessMatrix; a string literal in checkObjectPermission))
  • content/docs/releases/v17/17-1.mdx (via allowExport (literal, a string literal in a comment in checkObjectPermission), allowRead (literal, a string literal in buildAccessMatrix; a string literal in checkObjectPermission))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 18 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 9dd93190b665549a3b5c5e029c0d6e0bc7b89e31 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b3e9fe35cc3845545f2d2a524a5516a2d4f5326e — the merge of head a5e7c92e8d13d3ca59e8f1031e18ee87d927fd6f into base 9dd93190b665549a3b5c5e029c0d6e0bc7b89e31, 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 b3e9fe35cc3845545f2d2a524a5516a2d4f5326e && git checkout b3e9fe35cc3845545f2d2a524a5516a2d4f5326e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9dd93190b665549a3b5c5e029c0d6e0bc7b89e31 a5e7c92e8d13d3ca59e8f1031e18ee87d927fd6f && git checkout -B drift-repro 9dd93190b665549a3b5c5e029c0d6e0bc7b89e31 && git merge --no-ff a5e7c92e8d13d3ca59e8f1031e18ee87d927fd6f

node scripts/docs-audit/affected-docs.mjs --json 9dd93190b665549a3b5c5e029c0d6e0bc7b89e31

⚠️ 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 9dd93190b665549a3b5c5e029c0d6e0bc7b89e31 → 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

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants