Skip to content

fix(plugin-sharing): a share link whose redactFields names no column of the object is refused at mint - #29293

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-gor7h6
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-gor7h6

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Requested by Steve · project thread
Fixes #29130

Clause-②: yes (narrowing)
Metadata-protocol: none

This PR widens one surface and narrows another, so it takes the (narrowing) arm. The widening is one new export on @objectstack/objectql/core (declaredColumnSet). The narrowing is the share-link mint: a redactFields entry naming no column of the object was accepted 201 on main, and is now refused 400 VALIDATION_FAILED with nothing minted (measured below on base and head). The changeset carries the **BREAKING** banner with the FROM → TO fix and the ADR-0087 disposition not-required (no-migration-prescription). Both packages stay at patch. Contract review 6576071712, M1.

维护者速读

  • 改了什么:创建分享链接时,redactFields 里每个字段名都必须是该对象真实存在的列,否则 400 拒绝,不生成链接;objectql 的 ./core 多导出一个现成函数 declaredColumnSet。
  • 为什么改:以前写错一个字段名也照样生成链接,那个字段一点没被隐藏,落地页却显示「已隐藏」,等于把本想藏起来的数据发给了所有持链接的人。
  • 风险与回滚:这是一个声明为 BREAKING 的收窄:以前能成功、但字段名写错的创建请求现在会被拒;正确的字段名、空列表、已存在的旧链接都不受影响。回滚即还原本 PR。
  • 先看:packages/plugins/plugin-sharing/src/share-link-service.ts(assertRedactFields 和它在 createLink 里的位置,在权限检查之后)、packages/plugins/plugin-sharing/src/share-link-redact-fields.test.ts、packages/objectql/src/core.ts。
  • 两分钟验证:pnpm --filter @objectstack/plugin-sharing exec vitest run --maxWorkers=2 src/share-link-redact-fields.test.ts,预期 11 个用例全过。
  • 需要你做的:无,席位落地。

What changes

  • ShareLinkService.createLink checks redactFields before anything is written, in two steps. First the shape: the list must be an array of strings. That check runs with the mint's other request-shape 400s (object, recordId, emailAllowlist), before any record is read. Then the columns: each entry must name a column a read of the object serves: the object's field map plus the platform-provisioned columns (id, created_at, updated_at). Otherwise it answers 400 VALIDATION_FAILED, naming every unknown entry once, and no link row is written. This is the request-shape family the mint already uses (object is required, emailAllowlist is required when audience=email); no new error code.
  • The column check runs after the authority check, so a caller with no authority over the record still gets its 403 and learns nothing about the object's columns.
  • An object with no checkable field map gets no verdict, and the list is kept as sent. That is declaredColumnSet's shared rule: undefined means no opinion.
  • The REST door (POST /api/v1/share-links) and the dispatcher twin both call createLink, so both answer the refusal with the door's existing envelope. No route edit was needed.
  • @objectstack/objectql/core re-exports declaredColumnSet from declared-read-columns.ts. That module says its list is the one every read and write door reads, so the mint asks it too and keeps no copy of the column list.
  • Changeset .changeset/29130-share-link-redact-fields.md (patch, both packages).

Forced test edits: none. No existing test changed. One ledger row was forced: the new suite's engine double is pinned to the engine's update/delete dispatch contract (assertEngineUpdateDispatch / assertEngineDeleteDispatch), so check:engine-double-contract asked for its two pinned rows in scripts/engine-double-contract.pinned.json (written with --write: 0 rows added or grown elsewhere, 0 lost). The same double holds the caller's limit and refuses a where combinator it does not implement, which is what check:objectql-double-limit and check:where-matcher ask of a new double.

Tests

New file packages/plugins/plugin-sharing/src/share-link-redact-fields.test.ts, 11 cases. The fixture schemas are shaped like the registry's, so id is not in the field map:

  • an unknown entry is refused: status, code, the entry named, no row stored;
  • mixed valid and unknown entries: only the unknown ones are named, deduplicated;
  • a non-array or non-string list is refused, no row stored;
  • a malformed list is refused before any record is read (the engine sees no find), even for a record the caller cannot see;
  • a valid entry is minted, and resolve withholds that field from the served record;
  • id, created_at and updated_at are accepted even when the field map omits them;
  • an omitted or empty list still stores null;
  • no field map: no verdict, the list is kept;
  • no authority: 403 FORBIDDEN comes first and does not echo the name;
  • REST door: unknown entry gives 400 VALIDATION_FAILED and stores no row; a valid entry gives 201.

Acceptance notes

  • Stored links are not migrated. Rows already holding a name that matches no column keep it. The resolve answer still lists that name among the hidden fields, so the landing page can still claim a name is hidden when there is nothing behind it. Filtering at resolve would mean changing the same list that drives redaction, which risks un-hiding a policy entry, so it is not in this PR. It is raised as an open question in the report.
  • No update or edit path exists for a link's redactFields (only create, list, revoke and resolve), so the mint is the only place the list enters the store.
  • Security-family card: the class is described here in the abstract. The tests use generic fixture names.

Verification

All readings were taken in this checkout at head 04c61e78, base 104cd1a5 (merge base with origin/main).

Premise on base (104cd1a)

A throwaway test minted with one misspelt name against an opted-in object. The row stored redact_fields: ["secrte"], and resolveToken answered redactFields: ["secrte"]. Exit 0 (MINTED ["secrte"], RESOLVED redactFields ["secrte"]). The file was deleted afterwards.

Package suites
  • pnpm --filter @objectstack/plugin-sharing test: Test Files 60 passed (60), Tests 1316 passed (1316).
  • pnpm --filter @objectstack/plugin-sharing typecheck: exit 0.
  • pnpm --filter @objectstack/objectql typecheck: exit 0 (at 38ded5e5; objectql is unchanged since).
  • Consumers (at 38ded5e5; the round-2 diff moves only the shape check's position). Runtime share-link domain tests (7 src/domains/share-links-*.test.ts files plus src/domain-handler-registry.test.ts): 8/8 files, 268/268 tests. Dogfood share-link files (share-link-combined-gates, share-links-self-list, showcase-client-liaison-fixtures, guest-grants-channel): 4/4 files, 41/41 tests.
  • ui/: git grep redactFields finds the console's resolve-answer readers and shape tests. The only ui/ test that mints (share-dialog-locale-13460.test.tsx) mocks the wire and sends no redactFields, so there was nothing to run against the service.
  • Lint (eslint --no-inline-config on the touched source/test files): exit 0.
  • Full dogfood battery (it applies under the (narrowing) arm): pnpm --filter @objectstack/dogfood test on 04c61e78, under the lock, after the ^... closure build. VERDICT command-exit 0; Test Files 388 passed, 3 skipped (391); Tests 3208 passed, 28 skipped (3236); no failures.
Ablation (negative pins)

Put the old accept back with scripts/ablation-replace.mjs, replacing the assertRedactFields(...) call with the raw input.redactFields ?? []. The anchor went from 1 to 0 hits and the blob from 7db89b579f0c to efb2844eef05. Result: 4 failed and 6 passed (10, at 38ded5e5, before the round-2 shape pin was added). The red cases were the three refusal pins and the REST 400 pin; the valid-mint, provisioned-column, empty-list, no-field-map and 403-ordering cases stayed green, as they should. Restore: the blob matched HEAD again (7db89b579f0c) and git diff HEAD was empty. The test imports the service from src/, so no dist leg was needed.

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 04c61e78 derived 71 commands. All 71 ran on this head and every one exited 0. --ran with exit codes recorded: 71 derived famil(ies) accounted for — 71 run, 0 NOT-MEASURED (a DERIVED zero — all 71 recorded an exit code and none of them is 3). This includes check-adr-0087-registration --base origin/main (exit 0): 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition — [BREAKING+clause-②-narrowing] not-required (no-migration-prescription). It also includes check-changeset-no-major and check-empty-changeset, both exit 0.

An earlier run on e57ba0d3 found three real reds, all about the new suite's engine double: check:engine-double-contract (update/delete not dispatch-pinned), check:objectql-double-limit (a find that ignored limit) and check:where-matcher (a matcher that would read a combinator as a field name). Each was fixed in the double, as the gate prescribes; no baseline grew. Two prerequisite exits from that run were cleared before the final run: the teardown-shape self-test's pinned fixture commit was fetched, and the seven unbuilt dist/ trees that check:dual-build-cjs-loads reads were built.


Generated by Claude Code

…s no column of the object

The mint stored redactFields as sent, so a misspelt entry stripped nothing at
resolve while the answer still listed it as hidden. createLink now judges each
entry against the column set a read serves (objectql's declaredColumnSet, newly
re-exported from @objectstack/objectql/core) and answers 400 VALIDATION_FAILED
naming the unknown entries, minting nothing.

Claude-Session: https://claude.ai/code/session_01ADHkmPZTPQs1sThmyLtPtR
Co-authored-by: Claude <noreply@anthropic.com>
…he engine's update/delete/limit contract

Claude-Session: https://claude.ai/code/session_01ADHkmPZTPQs1sThmyLtPtR
Co-authored-by: Claude <noreply@anthropic.com>
… where combinator it does not implement

Claude-Session: https://claude.ai/code/session_01ADHkmPZTPQs1sThmyLtPtR
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/plugin-sharing, touching 3 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/objectql/src/core.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/permissions/system-context.mdx (via createLink (symbol, a method of class ShareLinkService))

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

  • content/docs/releases/v14.mdx (via createLink (symbol, a method of class ShareLinkService))

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
  • 1 changed file(s) yielded no anchor (packages/objectql/src/core.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: VALIDATION_FAILED (literal, 38 pages)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 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; 101 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 — 25 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 276ca307c5a1b2d9f83cd22c54edc14c1c1351c1 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 276ca307c5a1b2d9f83cd22c54edc14c1c1351c1

⚠️ 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 276ca307c5a1b2d9f83cd22c54edc14c1c1351c1 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Contract-review-claim: session_01ADHkmQjPiXgNoSaEpqeGvY · head 38ded5e5d522fa1001e1d691cc4edc389d1c07c3 · 2026-11-02T11:12Z

The lane seat runs this PR's review in an isolated reviewer. The PR declares Clause-②: yes (widening) for the new declaredColumnSet re-export, and its share-link mint also narrows (unknown redactFields entries are refused). So the review runs at CONTRACT_REVIEW_TIER with ④ over the whole non-prose diff.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 38ded5e5d522fa1001e1d691cc4edc389d1c07c3
Local-runs: none
Scope: packages/objectql/src/core.ts, packages/plugins/plugin-sharing/src/share-link-service.ts, packages/plugins/plugin-sharing/src/share-link-redact-fields.test.ts, scripts/engine-double-contract.pinned.json + packages/objectql/src/declared-read-columns.ts, packages/objectql/src/index.ts, packages/plugins/plugin-sharing/src/share-link-routes.ts, packages/runtime/src/domains/share-links.ts, packages/spec/src/contracts/share-link-service.ts, packages/types/src/share-link-resolve-answer.ts, scripts/pm/clause2-line.mjs, scripts/pm/record-recognisers.mjs, scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs, .changeset/29130-share-link-redact-fields.md (read for ②)

Reviewed read-only at 2026-11-02T11:31Z: the net diff origin/main...38ded5e5, card #29130 (body and every comment, claims 6573655861 and 6573762130 and the os-dev-report 6575674492 included), PR #29293 (body, file list), and the head's check-runs. Check-runs at posting: 48 on the head, all completed — 35 success, 13 skipped, 0 failure/neutral/cancelled (polled to completion before this record was written). The gate verdicts are green; the FAIL below is the record's own, on the declaration carrier.

① Derived judgments

  • Public surface, @objectstack/objectql, entry ./core — gains one value export, declaredColumnSet, re-exported from declared-read-columns.ts. Read in the tree: declared-read-columns.ts already exports it and engine.ts consumes it at eight sites; src/index.ts does NOT re-publish ./core (its only core lines are two @objectstack/core re-exports), so the widening lands on ./core alone, as the PR and the revised claim state. Right. A widening of a published export; no type changes, no signature change.
  • REST accept set, POST /api/v1/share-links (and the dispatcher twin packages/runtime/src/domains/share-links.ts) — redactFields is now refused 400 VALIDATION_FAILED when (a) it is not an array of strings, or (b) any entry is outside declaredColumnSet(schema); both doors pass the body's redactFields straight to createLink, the REST door maps err.status/err.code via sendError, the dispatcher via deps.errorFromThrown (reads status), so both answer 400 with the existing envelope. Right: an input accepted 201 on main is refused on head — a NARROWING of a published accept set, exactly what the changeset's own prose says ("The mint now narrows").
  • Accept set kept — omitted/null/[] still store null; names in the field map or in PLATFORM_PROVISIONED_COLUMNS (id, created_at, updated_at) are accepted; a schema with no checkable field map (absent, array, empty) returns undefined from declaredColumnSet and the list is stored as sent. Right — this is the shared rule of declared-read-columns.ts and the test pins each arm (doc, bare, unmapped).
  • Ordering — the check sits after the visibility/A′ authority block and before assertEligible, so an unauthorised caller gets 403 FORBIDDEN without learning column names. Right; pinned by the 403 test (message does not echo the name).
  • No new error code — VALIDATION_FAILED already issued by this mint (object is required, emailAllowlist is required…). Right.
  • Stored rows — untouched; resolveToken's union policy.redactFields ∪ row.redact_fields unchanged. Right, and the residue is named in ③.
  • Ledger scripts/engine-double-contract.pinned.json — two rows (delete, update, pinned: 1) for the new test file, inserted in the file's sort position (after share-link-enforcement-context.test.ts, before sharing-rule-provenance.test.ts), same shape as neighbours; the new double does call assertEngineUpdateDispatch/assertEngineDeleteDispatch. The gate's output, nothing else changed in the file.

② Semver level

  • Level: patch on both packages — right. Under ruling A (check-changeset-no-major.mjs and AGENTS.md Post-Task Checklist §3) the level follows Metadata-protocol: alone; this diff changes nothing a metadata author writes (none), so every entry is patch. A non-protocol widening or narrowing "ships patch and keeps its BREAKING banner and ADR-0087 disposition". skip-changeset would be wrong (two released packages move); the changeset lists both. ✓
  • Metadata-protocol: none — right.
  • Clause-②: yes (widening) — WRONG declaration for this diff. Must-fix (M1). The one reader, scripts/pm/clause2-line.mjs, spells out the four combinations: yes/yes (widening) = a widening; no (narrowing) = breaking, not a widening; and yes (narrowing) — "a diff that widens one surface and narrows another. Both facts are true and both are read." This PR is that diff by its own account (and by the seat's original claim 6573655861, which read no (narrowing)). AGENTS.md allows "at most one arm", and the reader resolves which one carries when both are true: the narrowing arm, because it is the only carrier of the breaking signal here — the changeset has no major, no **BREAKING** banner and no !, so check-adr-0087-registration.mjs signal (4) is the ONLY thing that can see this narrowing, and with (widening) it sees nothing and asks for no ADR-0087 disposition. That is the feat(platform-objects): sys_job.timezone and sys_report_schedule.timezone are validated against the IANA domain #16296 hole the arm was built to close, reopened by prose. record-recognisers.mjs#reviewFace reads the same arm: with the paths deciding no face, yes (narrowing) on any carrier ⇒ the narrowing face (CONTRACT_REVIEW_TIER, ④ owed); yes (widening) ⇒ the declaration face (TIER_DEFAULT, no ④). So the line as written also under-routes the review the diff owes — the dispatching seat compensated by hand in this brief, but the committed carrier is what the queue guard reads. Prose ("The mint now narrows") is not read by any gate.
    Fix: Clause-②: yes (narrowing) on the PR body and in .changeset/29130-share-link-redact-fields.md; then, because the changeset declares breaking, exactly one ADR-0087 disposition marker in its body (the HTML-comment form adr-0087: not-required (CATEGORY) WHY, category from the gate's own print — no spec key moves and no migration ledger entry applies) and the migration line AGENTS.md asks of a breaking changeset (the FROM→TO is already in prose: "correct the name, or drop it" — keep it, under a **BREAKING** banner). Level stays patch.
  • Changeset body otherwise follows the diff: both packages, the narrowing stated, the unchanged set stated, stored rows stated, the new ./core export named. ✓

③ Boundary flags

  • Deviation: scripts/engine-double-contract.pinned.json outside the claim's file surface — accepted. Gate-forced coverage ledger, judged in ① as the gate's output. Not a shrink-only baseline.
  • Deviation: Clause-② copied as yes (widening) per revised claim 6573762130 — ⛔ not accepted; see ② M1. The revised claim's instruction to put the narrowing "in the PR body" as prose contradicts the reader it must satisfy. The seat ruling on the single-source route (re-export declaredColumnSet) stands; the declaration spelling does not.
  • Deviation: closure build rerun with --workspace-concurrency=2 — process note, nothing to judge.
  • Open question 1 (stored rows still list an unknown name at resolve; filter?) — A (this PR) agreed. B (display-only filter in @objectstack/types buildShareLinkResolveAnswer) is a separate card on a types surface; C (filter the effective set in resolveToken) is rightly refused — the same list drives redaction and a gap in the column set would un-hide a policy entry. Escalation: file B as a follow-up card; not a condition of this PR.
  • Open question 2 (no checkable field map ⇒ no verdict; fail closed instead?) — A agreed. It is declaredColumnSet's shared rule on every read/write door, and a non-system, non-permissive caller is refused 422 SHARING_NOT_ENABLED before the check when getSchema answers nothing (read: getPolicy(undefined) ⇒ enabled: false). Only permissive/system callers reach the mint without a schema, and those bypass the policy by design (system-context ledger row 38).
  • Open question 3 (system columns; update path) — as implemented: provisioned columns accepted exactly when declaredColumnSet holds them; no update/edit verb exists for a link's redactFields (create/list/revoke/resolve only, read in share-link-routes.ts and the dispatcher), so the mint is the only entry.
  • Docs drift advisory (content/docs/permissions/system-context.mdx row 38 names createLink) — read: the row describes the system-context bypass of the enabled check, which this diff does not touch; nothing falsified. content/docs/releases/v14.mdx is release-owned and untouched. ✓
  • Dogfood battery — n/a per the report (no ! in title). Note: once M1 lands as yes (narrowing), re-read whether the dev-report's dogfood clause (Clause-② arm is (widening)) still exempts the PR; the targeted share-link dogfood files already ran 41/41.

④ Design judgment

packages/plugins/plugin-sharing/src/share-link-service.ts — holds against the cited code and the card's scenario.

  • assertRedactFields judges against declaredColumnSet(schema) where schema = this.engine.getSchema?.(input.object) — the same registry object resolveToken reads for its policy and the same set the engine shapes rows to on every read (engine.ts find/findOne/insert/update/delete sites), so "a name is accepted exactly when the read serves a column by that name" is true for storage-backed columns. The card's repro (sumary ⇒ 201 ⇒ served in full under a "hidden" notice) now ends at the mint with 400 and no row. ✓
  • Break attempts: '' and whitespace names are refused (not in the set) ✓; duplicates in a valid list are stored as sent (pre-existing; harmless, resolve builds a Set) ✓; internal: true fields are declared and so redactable (harmless — already omitted by the read) ✓; a name present only in the object policy's redactFields is not consulted (right — the per-link list is the input under judgment; the policy list is an authoring question outside this card) ✓; permissive mode with no schema ⇒ no verdict, list kept (the shared rule; ③ Q2) ✓; the refusal message names only the unknown entries, deduplicated, and nothing about valid ones (pinned) ✓.
  • Residual edge (observation, no finding): a key the served record carries that the field map does not declare — one an afterFind hook or expand adds after the engine's declared-column shaping — was redactable on main (the name matched a served key) and is now refused as "does not have". declared-read-columns.ts names such keys as "the engine's or the hook's, not storage's". The mint's rule is the right one for the card (a name must have a column behind it); the docblock's "exactly when there is something for it to hide" is one notch stronger than the code. No change asked.
  • Should-fix (S1): the request-SHAPE half of the check (Array.isArray / typeof name === 'string') runs after the authority read, unlike the mint's other 400 request-shape checks (object, recordId, emailAllowlist), which run before any DB read. Only the COLUMN half needs the authority ordering (it is what leaks column names). Splitting the shape check up to the request-shape block keeps a malformed body from paying a record read and keeps the mint's own 400-before-403 convention for shape; the 403-first test stays true because it sends a well-shaped list. Not a FAIL on its own.

packages/objectql/src/core.ts — holds. One value re-export, source already exported from its module and already consumed by engine.ts; comment states the consumer and the single-list reason. ./core is the lean entry, index.ts does not fan it out, so the widening is exactly the one declared. ✓

packages/plugins/plugin-sharing/src/share-link-redact-fields.test.ts — holds against the scenario. Fixtures shaped like the registry's (id outside the field map; bare carries no timestamps in the map) so the provisioned-column arm is really exercised; every refusal pinned on status + code + row-count, no bare toThrow(); the 403-ordering case checks the name is not echoed; the REST case mounts the real registerShareLinkRoutes and reads the envelope (success: false, error.code). The double pins update/delete to the engine's dispatch contract, honours limit and refuses $ combinators — what the three gates asked. The mixed case asserts the bdoy, titel ordering and dedup against the message spelling in the service, which is the right pin for "names every unknown entry once". ✓

scripts/engine-double-contract.pinned.json — the gate's output (①), judged only as such. ✓

Findings: M1 (must-fix) — Clause-②: yes (widening) is the wrong carrier for a diff that widens ./core AND narrows the share-link mint; spell yes (narrowing) on the PR body and the changeset and add the ADR-0087 disposition marker the gate then requires (level stays patch). S1 (should-fix) — move the array-of-strings shape check of redactFields before the record read, beside the mint's other request-shape 400s.

Implemented-by: claude/project-thread-gor7h6
Reviewed-by: session_01ADHkmQjPiXgNoSaEpqeGvY

VERDICT: FAIL

…uest-shape checks; declare the mint narrowing as breaking

Claude-Session: https://claude.ai/code/session_01ADHkmPZTPQs1sThmyLtPtR
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Contract-review-claim: session_01ADHkmQjPiXgNoSaEpqeGvY · head 04c61e7 · 2026-11-02T12:38Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 04c61e78e8bbf72cc36d704295c0e6670d59c87d
Local-runs: none
Scope: packages/objectql/src/core.ts, packages/plugins/plugin-sharing/src/share-link-service.ts, packages/plugins/plugin-sharing/src/share-link-redact-fields.test.ts, scripts/engine-double-contract.pinned.json + packages/objectql/src/declared-read-columns.ts, packages/objectql/src/index.ts, packages/plugins/plugin-sharing/src/share-link-routes.ts, packages/runtime/src/domains/share-links.ts, packages/spec/src/contracts/share-link-service.ts, scripts/pm/clause2-line.mjs, scripts/pm/record-recognisers.mjs, scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs, .changeset/29130-share-link-redact-fields.md (read for ②)

Round 2, rendered read-only at 2026-11-02T12:41Z. Supersedes record 6576071712 (FAIL, on head 38ded5e5). Inputs: the round-2 delta 38ded5e5..04c61e78 (one commit), the net diff origin/main...04c61e78, card #29130 with every comment (the round-2 os-dev-report 6577314563 included), the PR body as rewritten at 2026-11-02T12:38Z, and the head's check-runs. Check-runs at posting: 52 on the head, all completed — 37 success, 15 skipped, 0 failure/neutral/cancelled, none still in progress (polled to completion before this record was written). The gate verdicts are green, Check Changeset, PR-body guards and Governed Surface Queue Guard included.

① Derived judgments

Re-read on the WHOLE net diff; the delta touches no accept set or public surface beyond moving one check earlier.

  • Public surface, @objectstack/objectql entry ./core — unchanged since round 1: one value export declaredColumnSet, index.ts does not fan ./core out. Right.
  • REST accept set, POST /api/v1/share-links and the dispatcher twin — redactFields refused 400 VALIDATION_FAILED when (a) not an array of strings or (b) an entry is outside declaredColumnSet(schema). Delta: (a) now runs in the request-shape block (after the emailAllowlist check, before readRecord), (b) stays after the authority block and before assertEligible. Same codes, same envelope, same stored row; the accept set is byte-for-byte the one round 1 judged. Right — a narrowing of a published accept set, now declared as such (②).
  • Ordering consequence of the delta — a malformed redactFields from a caller with no authority now answers 400 instead of 403. The shape refusal reveals nothing about the object or the record (it is a statement about the request body alone), so the 403-before-anything rule that guards column names is intact; the column check is still 403-first and the test pins it. Right.
  • Accept set kept — omitted/null/[] ⇒ null stored; provisioned columns accepted; uncheckable field map ⇒ no verdict (assertRedactFieldColumns also returns at once on an empty list, so an empty list never consults the schema). Right.
  • Ledger — unchanged since round 1, the gate's output. Right.

② Semver level

  • M1 — RESOLVED. .changeset/29130-share-link-redact-fields.md at the head and the PR body both carry Clause-②: yes (narrowing) and Metadata-protocol: none. The changeset carries a **BREAKING**: banner that names the door, the old and new answers and the FROM → TO fix (correct the name, or drop the entry), and EXACTLY ONE ADR-0087 disposition marker in the HTML-comment form the gate reads, category no-migration-prescription (one of the gate's CATEGORIES), with a reason that is true of the tree: no metadata key, spec schema or stored row moves, so there is nothing for objectstack migrate meta to rewrite. Read against clause2-line.mjs: yes (narrowing) is the spelling for a diff that widens one surface and narrows another; check-adr-0087-registration.mjs reads signals BREAKING and clause-②-narrowing and finds the disposition; reviewFace now routes the PR to the narrowing face (CONTRACT_REVIEW_TIER, ④ owed) — the face this record serves.
  • Level: patch on both packages — right (ruling A: the level follows Metadata-protocol: alone; none ⇒ patch; a non-protocol narrowing ships patch and keeps its BREAKING banner and ADR-0087 disposition, which it now does). skip-changeset would be wrong; both released packages are listed.
  • Changeset body follows the diff: narrowing stated, unchanged set stated, stored rows stated, the ./core export named. The round-2 placement of the shape check is covered by the existing "A redactFields that is not an array of strings answers the same" sentence (the status and code did not change, only where it is asked). ✓

③ Boundary flags

  • M1 / S1 round-2 deviations — the dev reports the commit was amended once locally before push (no pushed history rewritten) and that the ADR-0087 marker is an HTML comment in the changeset file (the form the gate requires, invisible in a published changelog by design). Both accepted.
  • Deviation: ledger outside the claim's file surface — accepted, as in round 1.
  • Open questions 1–3 — unchanged from round 1 and answered there: Q1 A now, B (display-only filter in @objectstack/types buildShareLinkResolveAnswer) as a follow-up card, C refused; Q2 A (the shared declaredColumnSet rule; a non-system, non-permissive caller is refused 422 SHARING_NOT_ENABLED before the check when no schema answers); Q3 as implemented. Nothing in the delta reopens them.
  • Dogfood battery — the dev ran the full battery under the (narrowing) arm (388 files / 3208 tests passed, 0 failures, per report 6577314563); not re-run here, the check-runs are the gate verdicts.
  • PR body prose nit (not reviewed, noted) — the 「先看」 bullet still names assertRedactFields, a function the delta split into redactFieldsShape and assertRedactFieldColumns; the English sections describe the two-step shape correctly. Prose only; the seat holding the body may refresh it.
  • Docs drift advisory — same two pages as round 1 (system-context.mdx row 38 and the release-owned v14.mdx); the delta changes nothing they state.

④ Design judgment

packages/plugins/plugin-sharing/src/share-link-service.ts — holds; S1 — RESOLVED.

  • redactFieldsShape(raw) is called at the mint's request-shape block, immediately after the emailAllowlist 400 and before readRecord is defined or run, so a malformed list pays no engine.find and answers 400 with the mint's other request-shape refusals. assertRedactFieldColumns(names, schema, object) keeps the column judgment after the visibility/A′ block and before assertEligible, so column names are still disclosed only to a caller with authority. The stored value is the shape-checked list (redact_fields: redactFields.length > 0 ? redactFields : null), unchanged in effect from round 1.
  • Break attempts on the split: null/undefined ⇒ [] ⇒ the column check returns at once without touching the schema ✓; a non-array ('body'), a non-string member ([42]) and an object are refused by shape (the existing three-shape test still runs against the service and now exercises the earlier site) ✓; the column check receives a readonly string[] and cannot see a non-string ✓; the message spellings are unchanged, so the bdoy, titel and the not-echoed-name pins still bind ✓; the permissive/system no-schema path is unchanged (no verdict) ✓.
  • Round-1 residual observation (a key served only via an afterFind hook or expand is not redactable) stands as an observation, not a finding.

packages/plugins/plugin-sharing/src/share-link-redact-fields.test.ts — holds; the S1 pin is real. The new case wraps engine.find on the same object the service holds (the service reads this.engine.find at call time, so the wrap is observed), sends redactFields: 'body' for a record the caller cannot see (recordId: 'missing'), and asserts 400 VALIDATION_FAILED with ZERO reads — which also proves the ordering claim against the 403 path: the same missing record with a well-shaped list still answers 403 first in the neighbouring case. 11 cases; no existing case changed. ✓

packages/objectql/src/core.ts — unchanged since round 1; holds. ✓

scripts/engine-double-contract.pinned.json — unchanged since round 1; the gate's output. ✓

Findings: none. M1 and S1 from record 6576071712 are both resolved on this head.

Implemented-by: claude/project-thread-gor7h6
Reviewed-by: session_01ADHkmQjPiXgNoSaEpqeGvY

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

席位核对 · ACCEPT · domain:server seat 1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY · 2026-11-02T12:43Z

Head 04c61e78e8, round-2 report 6577314563 (on #29130), claim 6573762130. The diff widens one surface (declaredColumnSet on @objectstack/objectql/core) and narrows another (redactFields on POST /api/v1/share-links), so it is declared Clause-②: yes (narrowing). A contract review is owed at CONTRACT_REVIEW_TIER: round-2 record 6577411412 on this head is PASS with no findings, and it supersedes FAIL 6576071712.

item reading
Shape Draft, base main, Fixes #29130, 5 files. Changeset: patch on both packages, Clause-②: yes (narrowing), a BREAKING banner with FROM → TO, and one ADR-0087 marker not-required (no-migration-prescription). PR body rewritten by the seat from the dev's r2 file at 2026-11-02T12:38Z. Not governed (check-governed-merges governed: false).
Rule A redactFields that is not an array of strings answers 400 VALIDATION_FAILED before any record read. A name outside the object's declared columns answers 400 after the authority 403. Omitted, null and [] store null.
Round 2 M1, the declaration arm, is resolved. S1 is resolved: the shape check moved before readRecord, and a new pin checks that a malformed list causes zero engine.find calls.
Evidence Dogfood VERDICT 0 (3208 passed / 0 failed), plugin-sharing 1316/1316, gates 71/71. CI on this head: 52 runs, 37 green, 15 skipped, none red. check-expected-skips OK (15, all in main's roster).
Prose nit, not filed The body's 「先看」 bullet still names assertRedactFields, which is now redactFieldsShape + assertRedactFieldColumns. This is reader-only and doesn't hold the landing.
Landing 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 12:45
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 2, 2026 12:45
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit 3ff32c4 Nov 2, 2026
53 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-gor7h6 branch November 2, 2026 13:31
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

1 participant