Skip to content

docs(adr-0120): the per-object batch upsert by id joins the create doors; pin the edit-without-read class - #28797

Merged
os-zhuang merged 2 commits into
mainfrom
claude/docs-upsert-door-adr0120
Nov 2, 2026
Merged

os-zhuang merged 2 commits into
mainfrom
claude/docs-upsert-door-adr0120

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

维护者速读

  • 改了什么:ADR-0120 的 2026-11-01 修订注记末尾再加一条同日注记,并在其「新建门」清单第一条补一句:带 id 的 POST /data/:object/batch upsert,对调用方读不到的行就是一扇新建门——已被占用但读不到的 id 只得到撞键(409 UNIQUE_VIOLATION)而不是匹配,没有新建权则得到新建的拒绝;同时记下 driver-memory 开启持久化时重复行会写入开发库这一已接受的残余。dogfood 用例的 HIDDEN 表新增「有编辑、无读取」这一类主体(无新建权、有新建权各一);protocol-batch-data.ts 的分叉注释补一句。零运行时改动。
  • 为什么改:QA 第三轮发现的安全加固(细节按披露裁决不公开)
  • 风险与回滚:只动 ADR、一处代码注释和一个测试文件,运行时行为不变;回滚即 revert 本 PR。今后若有人改动该分叉的选臂规则,新用例会转红,这是有意的。
  • 先看:docs/adr/0120-unique-scope-vocabulary-and-null-safe-tenant-uniqueness.md(风险 hunk:修订注记末尾新增的日期注记,以及清单第一条的补句,核对它与 2026-11-01 修订的裁决范围一致)→ packages/qa/dogfood/test/upsert-by-id-caller-read.dogfood.test.ts(HIDDEN 表新增的两行,及「不存在的 id」用例里无新建权的分支)→ packages/metadata-protocol/src/protocol-batch-data.ts(分叉注释第一条理由的补句)。
  • 两分钟验证:pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=1 test/upsert-by-id-caller-read.dogfood.test.ts,预期 1 个文件、36 个用例全绿(原有 28 个加新增 8 个)。
  • 需要你做的:本 PR 触及 docs/adr/**(Tier H),请审阅新增注记的措辞后亲手合并或入队。

What changes

A docs + pin + comment PR. No runtime change.

  • ADR-0120, the 2026-11-01 amendment. A dated note is appended to the amendment in the record's own amendment form, and the amendment's first fact (the create-door list) gains one clause pointing at it. The note records that POST /data/:object/batch with operation: 'upsert' is a create door for a row naming an id the caller cannot read: the door's match key is the id, read as the caller; a readable row takes the update arm, an unreadable one takes the create arm with the create's answer. Contracts it states:

    • a held id the caller cannot read meets the collision, never a match: 409 UNIQUE_VIOLATION on a store that enforces the primary key, the same row error a batch create naming that id carries, identical for a row hidden inside the caller's organization and one held by another; import upsert matches on the mapping's declared key fields, this door on the id itself;
    • a caller without the create grant gets the create's refusal, the same for a held id and for one that names no row;
    • on driver-memory the create arm lands a second row with that id, and with that driver's persistence configured the duplicate is written to the development store; recorded as the same accepted residual (the development and test driver declares no primary key).

    Provenance in the note: ruling record 5985885287, applied to this door by commit 7b62f0a77. The status line is unchanged; its existing 2026-11-01 entry already points at the amendment this note extends.

  • Dogfood pin, upsert-by-id-caller-read.dogfood.test.ts (stock showcase): the HIDDEN table gains the edit-without-read principal class on showcase_semantic_zoo (public_read_write, so the platform's update ownership floor does not bind it, and no baseline set grants anything on it), once without and once with allowCreate. Each table row now carries the create's expected answer (403 PERMISSION_DENIED without the create grant, 409 UNIQUE_VIOLATION with it); the held-id case asserts the upsert's body equals a batch create naming that id and the stored row is byte-identical afterwards; the unknown-id case answers as the create does (created with the grant; without it, the create's refusal, nothing stored, and the same answer a held id gets). The guard case reads each new principal's grants through /security/explain (object_crud verdict grants on update, denies on read), its by-id read (403) and its create (201 / 403).

  • Fork comment, packages/metadata-protocol/src/protocol-batch-data.ts: one clause in the first reason of the reversal paragraph naming the earlier fork's third outcome, stated as the rule. Comment only.

Tests

All at head 751c15af6, exit codes captured before any pipe.

  • Dogfood file, under the verify lock, --maxWorkers=1: Test Files 1 passed (1), Tests 36 passed (36), lock VERDICT command-exit 0. 28 before plus 8 new (two classes, atomic and not, held id and unknown id).
  • @objectstack/metadata-protocol unit tests that drive batchData (9 files: protocol.upsert-existence, protocol.batch-atomic, protocol.batch-row-driver-text, protocol.batch-row-http-status, protocol.batch-row-sandbox-refusal, protocol.bulk-record-not-found, protocol.readonly-insert, protocol.write-response-internal-fields.tripwire, protocol.bracketed-refusal-opener-absence), under the verify lock: Test Files 9 passed (9), Tests 150 passed (150), lock VERDICT command-exit 0.
  • Type check: tsc --noEmit --listFiles over the dogfood package's own tsconfig.json (what its typecheck script runs), under the verify lock: exit 0, 0 errors; the edited test is in the program (2261 files listed, 435 of them under packages/qa/dogfood/test/).
One-shot ablation of the new pin (reverted fork, measured, restored with proof)

Driven by scripts/ablation-replace.mjs in wrap mode (trap-armed restore, absolute path), the whole sequence under the verify lock (VERDICT command-exit 0).

  • Mutate leg: the fork's caller read replaced by a system-context existence probe. Anchor 1 -> 0, replacement 0 -> 1, blob f26864308 to 8037e64df. pnpm --filter @objectstack/metadata-protocol build exit 0. ablation-dist-preflight exit 0: marker present in 2 built files.
  • Run: Tests 16 failed | 20 passed (36). The six new cases are among them: the held-id case for both new classes, atomic and not (4), and the unknown-id case of the no-create class, atomic and not (2). The other ten are the existing hidden-row cases. The with-create class's unknown-id case stays green, as it should: an unknown id takes the create arm under either fork.
  • Restore leg: blob back to the HEAD blob f26864308, git diff HEAD empty for the path, whole-tree git status --porcelain empty. Rebuilt (exit 0); ablation-dist-preflight --absent exit 0: marker absent from all 24 built files, tree clean.

Nothing of the ablation is in the tree or the branch.

Acceptance notes

  • The import contrast in the note: the import door also matches as the caller and creates on a miss, so what separates the two doors is the match key, the mapping's declared key fields there and the id itself here. That is what makes this door's answer to a held-but-unreadable id the collision rather than a match, and the note says it that way so the sentence holds for both doors.
  • Not run locally, by design: test:repo (this diff edits none of its tests and the derivation names none of it; Test Core runs it), and the repo-wide scans CI owns.
  • pnpm check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (exit 3: seven packages outside the dogfood closure had no dist/). Built those seven with turbo (all cache hits) and re-ran it: exit 0. Both readings are recorded here; the list below carries the measured one.
  • No changeset. The diff touches docs/adr/**, the private @objectstack/dogfood package, and one code comment in packages/metadata-protocol/src. Measured: the comment's text has 0 hits in the built dist/ (the package's files[] ships dist), against the positive control callerMayReadObject with 2 hits. skip-changeset is declared at creation; pnpm check:empty-changeset exit 0.

Verification

Head 1e7ced88b. It adds one commit on top of 751c15af6, from contract review 6563439682: two wording corrections in the ADR-0120 note, prose only and in that one file. The driver-memory bullet now names the driver's public persistence option ("a custom adapter included") in place of a private field name. The facts-carry sentence now reads "holds for that arm, which no readable row enters". Re-run at 1e7ced88b, exit codes captured before any pipe:

  • node scripts/check-adr-links.mjs :: exit 0 (967 relative link destinations resolve)
  • pnpm check:adr-index :: exit 0 (docs/adr/README.md is current)
  • pnpm check:doc-anchors :: exit 0 (857 internal fragment links resolve)
  • pnpm check:doc-authoring :: exit 0

The dogfood run, the unit tests, the type check, the ablation and the derived gate families below were taken at 751c15af6. The commit after it touches neither test file nor any code path.

At 751c15af6:

  • node scripts/check-adr-links.mjs :: exit 0 (967 relative link destinations resolve)
  • pnpm check:adr-index :: exit 0 (docs/adr/README.md is current)
  • pnpm check:doc-anchors :: exit 0 (857 internal fragment links resolve)
  • pnpm check:doc-authoring :: exit 0
  • pnpm check:nul-bytes :: exit 0, and a control-byte scan of the three changed files: no hits
  • node scripts/pm/dispatch-gates.mjs --ran :: exit 0, 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN, every line carrying its exit code
Derived gate families (dispatch-gates --commands, reconciled with --ran)

Each line is command :: exit code, captured before any pipe, at 751c15af6.

  • node scripts/check-adr-links.mjs :: exit 0
  • node scripts/check-adr-links.mjs --self-test :: exit 0
  • node scripts/check-closing-keyword-parity.mjs :: exit 0
  • node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0
  • node scripts/check-comment-mask-adoption.mjs :: exit 0
  • node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0
  • node scripts/check-comment-mask-corpus.mjs :: exit 0
  • node scripts/check-dts-emitted.mjs --self-test :: exit 0
  • node scripts/check-issue-citations.mjs :: exit 0
  • node scripts/check-plugin-teardown-shape.mjs :: exit 0
  • node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0
  • node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0
  • node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0
  • node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0
  • node scripts/docs-audit/check-affected-docs.mjs :: exit 0
  • node scripts/docs-audit/check-drift-comment.mjs :: exit 0
  • pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0
  • pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0
  • pnpm --filter @objectstack/spec run check:empty-state :: exit 0
  • pnpm --filter @objectstack/spec run check:liveness :: exit 0
  • pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0
  • pnpm --filter @objectstack/spec run check:variant-docs :: exit 0
  • pnpm check:adr-anchors :: exit 0
  • pnpm check:adr-index :: exit 0
  • pnpm check:adr-symbol-anchors :: exit 0
  • pnpm check:ci-filter-parity :: exit 0
  • pnpm check:closed-ceremony-text :: exit 0
  • pnpm check:cross-package-test-inputs :: exit 0
  • pnpm check:dispatcher-error-vocabulary :: exit 0
  • pnpm check:doc-authoring :: exit 0
  • pnpm check:driver-memory-census :: exit 0
  • pnpm check:dts-closure :: exit 0
  • pnpm check:dual-build-cjs-loads :: exit 0
  • pnpm check:durability-log-level :: exit 0
  • pnpm check:engine-double-contract :: exit 0
  • pnpm check:error-status-conformance :: exit 0
  • pnpm check:gitlink-declared :: exit 0
  • pnpm check:issue-citations :: exit 0
  • pnpm check:keyed-text-bounds :: exit 0
  • pnpm check:lean-entry-closure :: exit 0
  • pnpm check:logger-receiver-detach :: exit 0
  • pnpm check:nul-bytes :: exit 0
  • pnpm check:objectql-double-limit :: exit 0
  • pnpm check:org-identifier :: exit 0
  • pnpm check:page-declaration-shape :: exit 0
  • pnpm check:platform-object-tenancy-census :: exit 0
  • pnpm check:pm-governed-merges :: exit 0
  • pnpm check:pm-prior-rulings :: exit 0
  • pnpm check:published-files :: exit 0
  • pnpm check:query-options-erasure :: exit 0
  • pnpm check:refd-timer-probe :: exit 0
  • pnpm check:registry-log-declared :: exit 0
  • pnpm check:rest-log-spy-declared :: exit 0
  • pnpm check:slot-lookup :: exit 0
  • pnpm check:sourcemap-no-sources-content :: exit 0
  • pnpm check:system-context-census :: exit 0
  • pnpm check:test-source-alias :: exit 0
  • pnpm check:tier-file-adoption :: exit 0
  • pnpm check:type-check-coverage :: exit 0
  • pnpm check:type-check-debt :: exit 0
  • pnpm check:undeclared-dep-imports :: exit 0
  • pnpm check:watch-hint-literal :: exit 0
  • pnpm check:where-matcher :: exit 0
  • pnpm check:doc-anchors :: exit 0
  • pnpm check:empty-changeset :: exit 0

Generated by Claude Code

…oors; pin the edit-without-read class

The ADR-0120 2026-11-01 amendment gains a dated note: `POST
/data/:object/batch` with `operation: 'upsert'` is a create door for a
row naming an id the caller cannot read, its collision and refusal
answers, and the driver-memory persisted-duplicate residual. The
dogfood pin adds the edit-without-read principal class (with and
without create) on an open-write object to its HIDDEN table, and the
fork comment names the old fork's third outcome.

Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Nov 1, 2026
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests labels Nov 1, 2026
@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 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 — 10 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 0aaad18fc052326bc78378ead5f7e789f64e072d → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 0aaad18fc052326bc78378ead5f7e789f64e072d

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 751c15af67fb98b63f946adc89d14b08a44467f9
Local-runs: none
Scope: docs/adr/0120-unique-scope-vocabulary-and-null-safe-tenant-uniqueness.md + packages/metadata-protocol/src/protocol-batch-data.ts (callerMayReadObject :135-144 and its docblock, runBatchDataLoop case 'upsert' :370-457, case 'create'), packages/core/src/utils/import-runner.ts (findArgsBase :830-834, findExisting :907-932, willUpdate / willCreate :1499-1500), packages/drivers/driver-memory/src/memory-driver.ts (persistence option :236-265, the no-primary-key docblock :476-480), packages/plugins/plugin-security/src/objects/default-permission-sets.ts (member_default :612-640 no-wildcard note, the owner_only_writes floor and its public_read_write relief :1299-1310), packages/plugins/plugin-security/src/explain-engine.ts (object_crud layer :2779-2801, verdict: grants|denies), examples/app-showcase/src/data/objects/semantic-zoo.object.ts (sharingModel: 'public_read_write' :28), packages/metadata-protocol/src/protocol.upsert-existence.test.ts (titles), packages/qa/dogfood/test/upsert-by-id-caller-read.dogfood.test.ts (both suites, on origin/main and at this head).

Read-only shape held: the diff at this head (git diff origin/main...FETCH_HEAD, three files), ADR-0120's 2026-11-01 amendment as merged by #28694, ruling 5985885287 (#21771), the record 6561724481 on #28721 (merged as 7b62f0a77, on main), the head's check runs (53 runs at reading time: 23 success, 18 skipped, 12 in progress, 0 failure), the PR's one comment (docs-drift). Nothing built or run. PR author is objectstack-fleet[bot]; Implemented-by is the branch. No claim line was posted by this reviewer: the record is dispatched by the PM seat, which holds the head.

Disclosure check (ruling 2026-10-29): the ADR note, the create-door clause, the fork-comment clause, the test header, the test titles and the PR body were read for any sentence describing how an earlier path could be abused. None found. The ADR note states contracts (match key, arm, answer) and names a commit; the test header names the principal class, the object and the grants each holds, and why the object was chosen (the floor's relief on public_read_write, no baseline grant), all as positions; the fork clause names the earlier fork's reachable arm and nothing about what that arm admitted; the PR body's ablation names which cases go red under a mutated fork, not what the mutated fork answered. The HIDDEN labels name grants and the OWD only.

① Derived judgments

No runtime code moves: the accept set and every public face of @objectstack/metadata-protocol are unchanged (the one .ts hunk is inside a comment; the PR body's measurement that the clause's text has 0 hits in dist/ against a positive control is consistent with a comment-only hunk). The judgments are therefore of the governed text against the code it describes, on main:

  1. "The by-id upsert matches on the row's id, read as the caller — the object-level read admission, then the caller's own read of that id, under the context its write runs with." Right. case 'upsert' with record.id: objectReadable ??= callerMayReadObject.call(this, object, context) (the security service's canReadObject with the loop's context; true when the context is undefined or the method is absent), then this.engine.findOne(object, { where: { id }, context }); the write that follows uses the same ctxOpt. Inside the atomic arm the context is the transaction's.
  2. "A row the caller can read is matched and takes the update arm, where the engine still judges the write." Right. if (existing) → engine.update(object, data, { where: { id } , ...ctxOpt }); the CRUD gate, the floor and the write-class RLS all still run there.
  3. "A row the caller cannot read ... takes the create arm: the same insert and the same row answer as operation: 'create' naming that id." Right. The else arm is engine.insert(object, { id: record.id, ...data }, { ...ctxOpt, onFieldsDropped }) with the same omitInternalFieldsFromWriteResponse and the same result shape as case 'create'; a thrown refusal or collision is enveloped by the same row builder as every other case. No code of the upsert's own.
  4. "Every fact above holds for that arm." Holds, read with the scoping the sentence carries ("for that arm"): a fresh id lands as given (201-class row); a collision answers 409 UNIQUE_VIOLATION and writes nothing on a PK-enforcing store; hidden-in-own-organization and other-organization rows answer alike (dogfood: stock showcase_inquiry / showcase_private_note / showcase_contact and the walled cross-organization personas, all deep-equal to a batch create); what leaves is bounded by the id's unguessability. The third fact's "visible to the caller" limb is vacuous inside the arm (a visible row is matched, so it never reaches the create arm); the note's own preceding sentence and its first bullet already say so. Not a contradiction; wording note under ③.
  5. "A held id the caller cannot read meets the collision, never a match. Import upsert matches on the mapping's declared key fields; this door's match key is the id itself." Right, and the dev's rephrasing of the fix(metadata-protocol): batchData's by-id upsert chooses its arm by what the caller can read #28721 record's "unlike import upsert" is the correct one. import-runner.ts: findExisting filters on the first matchFields key set whose fields are non-blank and calls p.findData(findArgsBase(...)), and findArgsBase spreads the caller's context, so import's match ALSO runs as the caller and an unreadable row is 'none'; willCreate = !willUpdate && (writeMode === 'insert' || writeMode === 'upsert'), so import also creates on a caller-read miss (the amendment's existing parenthesis for import says exactly this). What separates the doors is the key the match runs on: the import's declared matchFields (which may or may not be id) versus this door's fixed id. The sentence as written holds for both doors.
  6. "A caller without the create grant gets the create's refusal instead, the same for a held id and for one that names no row." Right. A caller with no find grant has canReadObject false (object admission asks the find grant), so no row read happens and every by-id row takes the insert arm; engine.insert's CRUD gate refuses 403 PERMISSION_DENIED ahead of the driver, so a held id and an unknown id answer identically, and nothing reaches the primary key. Pinned by the new CREATE_REFUSED row (held-id case: upsert deep-equals batch create, and the plain create's status/code; unknown-id case: same, nothing stored, and held deep-equals ups).
  7. "On driver-memory the create arm lands a second row with that id ... when that driver runs with persistence ... the duplicate is also written to that development store. Accepted as the same residual." Right in substance: the driver declares no primary key (memory-driver.ts :476-480) and the amendment already records the duplicate for every create door; a persisted duplicate through the adapter is the residual the fix(metadata-protocol): batchData's by-id upsert chooses its arm by what the caller can read #28721 record flagged, now written down. The naming "a custom persistenceAdapter" is loose: the driver's public option is persistence: 'file' | 'local' | 'auto' | false | { type, path, key, autoSaveIntervalMs, adapter }; persistenceAdapter is the driver's private field, not an option an operator writes. Should-fix (1).
  8. Create-door list clause — "POST /data/:object/batch with operation: 'upsert' for a row naming an id the caller cannot read or an id that names no row". Right and complete for the by-id shape (an id-less upsert row is a create with no caller-supplied id, outside the list's subject). It points at the dated note, which is where the two particulars live.
  9. Provenance — "ruling record 5985885287 applied to the per-object batch upsert by commit 7b62f0a77": 7b62f0a77 is fix(metadata-protocol): batchData's by-id upsert chooses its arm by what the caller can read #28721's merge commit, on main. The ruling's text ("on the write doors, a row the caller cannot read is a row that does not exist") is quoted faithfully. The two pin links resolve (check-adr-links and Check Documentation Links green).
  10. Amendment form and Status line — the note is appended inside the existing 2026-11-01 amendment blockquote, in the same bold-headed dated form, with its ruling record and applying commit; the Status line's existing Amended (2026-11-01 ...) entry already routes a reader to "the note under the business-requirement matrix", of which this is the tail. The 2026-08-22 D4 amendment is precedent for a dated note that does not add a Status entry. Acceptable as is.
  11. Fork-comment clause (protocol-batch-data.ts :404-409) — "and that fork could reach the update arm for a principal the caller-read rule now sends to the create arm". Accurate: the pre-fix(metadata-protocol): batchData's by-id upsert chooses its arm by what the caller can read #28721 fork chose on probeRecord existence, so every existing row took the update arm regardless of the caller's read; the current rule sends an unreadable row to the create arm. Neutral: it names an arm and a rule, no principal class, no admission, no landing. It closes should-fix 4 of 6561724481 as asked.

② Semver level

skip-changeset is right. The diff touches docs/adr/** (prose, governed), @objectstack/dogfood ("private": true), and one comment inside a published package's source; nothing published changes (a comment is not in the built dist/, and the body's measurement agrees). No Clause-②: line in the PR body: correct for a diff that moves no accept set and publishes nothing; the at-tier review is owed by the governed path, which this record serves. Check Changeset and PR-body guards green at the head.

③ Boundary flags

  • Author objectstack-fleet[bot]; Implemented-by is the dev branch. No open_questions block; the body's "Acceptance notes" are each answered here.
  • Pin soundness (the edit-without-read class). showcase_semantic_zoo declares sharingModel: 'public_read_write'; the platform's write floor owner_only_writes is relieved by "a public_read_write update" (default-permission-sets.ts :1299-1310), so on this object an edit grant reaches every row and the floor's domain is left — the right object for a class the floor would otherwise mask. member_default names no business object and carries no wildcard (member_default's * wildcard object grant (C/R/E) union-merges into every org member — app-side explicit-allow object gates are erased on three axes #5491), and no showcase set names the object (git grep showcase_semantic_zoo over examples/app-showcase/src hits the object and its translations only), so the edit grant the test writes is the principal's only grant there. Non-vacuity is runtime-guarded, not assumed: the guard asserts /security/explain object_crud verdict grants on update and denies on read (the layer's exact vocabulary, explain-engine.ts :2800), the by-id GET 403, and the create 201 / 403 by class. Reading grants through explain rather than through a by-id write is the sound choice: a write would itself drive the fork under test. The one thing the guard does not prove is that the floor is relieved on the object (an update CRUD grant says nothing about rows); that fact is read off main here and is what makes the held-id case go red under the ablation (an update arm the floor did not stop), which the ablation measured. Sound.
  • Ablation arithmetic. 36 = 28 + 2 classes × 2 arms × 2 cases. Under an existence-probe fork: held-id cases of both new classes red (4: the edit-only class gets the update arm's success instead of the create's 403, the edit+create class the update instead of 409); the no-create class's unknown-id case red (2: its trailing held comparison); the with-create class's unknown-id case green (2: an unknown id inserts under either fork); the pre-existing hidden-row cases red (stock 3 personas × 2 arms = 6, walled 2 personas × 2 arms = 4). 6 new + 10 existing = 16 red, 20 green. Consistent with the body's claim.
  • The pin is not run by this PR's CI, by design. Dogfood Regression Gate (n/3) has if: github.event_name != 'pull_request' && != 'merge_group'; it was skipped on this head and there is no push-event run for the sha. The evidence that the 8 new cases are green is the dev's local run (36/36 under the verify lock), as it was for fix(metadata-protocol): batchData's by-id upsert chooses its arm by what the caller can read #28721. Not a must-fix; the PM seat should read the first push or scheduled Dogfood Regression Gate that covers the landed sha. Should-fix (3).
  • Head check-runs at reading time: 0 failures; Type Check, Test Core, Lint families still in progress. Landing check ② (all green) is the PM seat's at landing time.
  • Docs drift (comment 6563356782): nothing listed; content/docs/** is prose face, lane ACCEPT.
  • Local-runs: none: nothing was built, run or replayed; the ablation figures are judged by arithmetic against the test tables, not reproduced.

④ Design judgment

docs/adr/0120 (the one hits file). The change holds against the code it cites: every contract sentence in the note is realized by case 'upsert' on main as traced in ① 1-6, the import contrast is realized by import-runner.ts as traced in ① 5, the driver residual by memory-driver.ts, and the two pins it names exist and pin what it says (the protocol seam battery's cases 1, 3 and 10; the dogfood held-id / unknown-id cases, stock and walled, atomic and not). First consumer: the fork itself, which already implements the text (the ADR records a landed decision, as its amendment form says), and check-adr-links / check-adr-index / check-doc-anchors as the gates that read the file. Execution plan: none owed; the should-fixes 2-4 of 6561724481 that this PR exists to close are closed (door listed with the persisted residual; pin by name; fork clause). Consistency with the existing amendment: the binding sentence ("no create door answers a collision on id differently by the visibility of the row that holds it") now covers this door, and the note adds no exception to it; ruling 5985885287 remains the reason an unreadable row is a create here, and the note says so in the ruling's words. Holds.

Implemented-by: claude/docs-upsert-door-adr0120
Reviewed-by: session_017xRBcVy4VXmkQjn2MGeaWk

VERDICT: PASS

Must-fixes: none. Should-fixes: (1) ADR note, driver-memory bullet — name the public option: "its opt-in persistence option, a custom adapter included" in place of "a custom persistenceAdapter" (a private field name); (2) ADR note, "Every fact above holds for that arm" — optionally "for that arm, which no readable row enters", so the third fact's visibility limb reads as scoped rather than as a claim about this door's matched rows; (3) PM seat: read the first push/scheduled Dogfood Regression Gate covering the landed sha, since the PR's CI does not run the dogfood pin by design. Tier H: the maintainer's approval of the governed text is still owed; this record is the at-tier PASS it waits on.

Reading time 2026-11-01T20:40Z.

…dable row enters

Two wording corrections to the batch-upsert note from the contract
review: the driver-memory bullet names the driver's public `persistence`
option (a custom adapter included) instead of a private field, and the
facts-carry sentence says the create arm is one no readable row enters.

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

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1e7ced88b8d69d66d21991e95e82bdef048fa153
Local-runs: none
Scope: docs/adr/0120-unique-scope-vocabulary-and-null-safe-tenant-uniqueness.md (delta 751c15af6..1e7ced88b, one commit, 4 lines) + packages/drivers/driver-memory/src/memory-driver.ts (persistence option :259-265), packages/metadata-protocol/src/protocol-batch-data.ts (case 'upsert' :370-457, read for the arm sentence).

Delta record by the same reviewer, on the full-review record 6563439682 (head 751c15af6, PASS): that record's ①–④ carry over unchanged for everything the delta does not touch. Read-only shape held: git diff 751c15af6..1e7ced88b, the new head's check runs (23 runs at reading time: 4 success, 4 skipped, 13 in progress, 2 queued, 0 failure). Nothing built or run. PR author objectstack-fleet[bot]; Implemented-by is the branch; still draft.

① Derived judgments

The delta is two prose edits inside the 2026-11-01 note of the ADR, both applying should-fixes 1 and 2 of 6563439682; no code, test, comment, link or Status-line change; no accept set or public face moves.

  1. "Every fact above holds for that arm, which no readable row enters, and two are particular to it" — right. case 'upsert' takes the insert arm only when the object admission is refused or the caller's own findOne by id answers nothing; a row the caller can read is matched and takes the update arm. So the amendment's third fact's "visible to the caller" limb is now read as scoped: inside the create arm there is no readable row, and the two visibilities the arm does see (hidden in the caller's organization, held by another) answer alike, which the note's first bullet states. The sentence no longer invites a reading that a matched row collides. Closes should-fix 2.
  2. "its opt-in persistence option, a custom adapter included" — right. The driver's public option is persistence: 'file' | 'local' | 'auto' | false | { type, path, key, autoSaveIntervalMs, adapter }; the custom adapter is persistence.adapter, and the private field persistenceAdapter is no longer named as if it were an option. Closes should-fix 1.
  3. No other line of the note, the create-door list clause, the fork comment or the dogfood pin moves (git diff --stat: one file, 4 insertions, 4 deletions). The disclosure reading of 6563439682 stands: both sentences state contracts and an option name; nothing describes an abuse path.

② Semver level

Unchanged: skip-changeset remains right (prose in docs/adr/** only). No Clause-②: line owed.

③ Boundary flags

  • No new dev flags; the commit message names the two should-fixes it applies and nothing else.
  • Head check-runs at reading time: 0 failures, most still in progress or queued; the Check Links / doc-anchor gates that read the ADR have not completed on this head yet. Landing check ② (all green) is the PM seat's at landing time.
  • Should-fix 3 of 6563439682 (read the first push/scheduled Dogfood Regression Gate covering the landed sha, since the PR's CI does not run the dogfood pin by design) is unaffected by this delta and still stands for the PM seat.

④ Design judgment

docs/adr/0120 (the one hits file): the two edited sentences hold against the code they describe (① 1-2), and the note's consistency with the existing amendment's binding sentence and with ruling 5985885287 is as judged in 6563439682 ④. Holds.

Implemented-by: claude/docs-upsert-door-adr0120
Reviewed-by: session_017xRBcVy4VXmkQjn2MGeaWk

VERDICT: PASS

Must-fixes: none. Should-fixes: none new; 6563439682's (3) remains for the PM seat at landing. Tier H: the maintainer's approval of the governed text is still owed; this record is the at-tier PASS on the current head.

Reading time 2026-11-01T20:50Z.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

ACCEPT → head 1e7ced88b8d69d66d21991e95e82bdef048fa153 · 2026-11-01T20:52Z

维护者速读

  • 改了什么:在 ADR-0120 的 2026-11-01 修订里补了两处。

    • 新建入口列表加上一句:批量 upsert 按 id 写入时,如果调用方读不到这个 id 对应的行,就按新建处理;
    • 新增一条带日期的注记:遇到"已存在但读不到"的 id,返回的是冲突,不是匹配;没有新建权限的调用方,无论 id 是否已存在,都收到与新建相同的拒绝;并写明 driver-memory 持久化下会出现重复行这一残留。

    此外,dogfood 测试加上了"有写权、无读权"这一类调用方(分有、无新建权两种),fork 那段注释补了一句中性说明。产品代码没有改动。

  • 为什么改:QA 第三轮发现的安全加固(细节按披露裁决不公开)。

  • 风险与回滚:只动 ADR、测试和一行注释,运行时行为不变。回滚即 revert 本 PR。

  • 先看:docs/adr/0120-unique-scope-vocabulary-and-null-safe-tenant-uniqueness.md 中 2026-11-01 修订下的新注记。

  • 两分钟验证:pnpm --filter @objectstack/dogfood exec vitest run test/upsert-by-id-caller-read.dogfood.test.ts,预期 36/36 通过。

  • 需要你做的:本 PR 改到受管文本 docs/adr/**,属于 Tier H,需要你在 GitHub 上批准(APPROVED review),之后由席位落地。

席位意见:收。本 PR 补齐 #28721 达档复核 6561724481 的 should-fix 2–4。

席位核对

  1. 形态:draft,目标 main;按披露裁决不建卡,正文没有关闭关键词。正文首行是签名,由席位按 dev 暂存稿写入。
  2. 前提实测:新增的 8 个用例在 ablation 下(fork 改回系统身份的存在性探测),其中 6 个变红,合计 16 红 / 20 绿;恢复后 blob == HEAD,--absent 检查 exit 0。
  3. 门禁:本地派生 63 项全部跑完;本 head 的文档门禁复跑结果都是 exit 0。本 head CI 共 54 项:37 success、17 skipped、0 失败。旧 head 751c15af6 上 Type Check 的那次红,是被新 commit 取代时取消造成的,不是真失败。
  4. 契约面:触及受管文本 → 已做 CONTRACT_REVIEW_TIER 完整复核 6563439682,结果 PASS;同一复核者对增量做了 delta 复核 6563473345,结果 PASS。两条措辞类 should-fix 已经落实。
  5. 范围外去向:复核的 should-fix 3 要求落地后去看 main 上第一次 push 或定时触发的 Dogfood Regression Gate,由席位跟进。
  6. 落地:受管面 Tier H(人合),149 行 → 席位执行 pr_ready;维护者批准后再执行 automerge_enable。

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 1, 2026 20:53
@os-zhuang
os-zhuang added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit 7107bde Nov 2, 2026
55 checks passed
@os-zhuang
os-zhuang deleted the claude/docs-upsert-door-adr0120 branch November 2, 2026 00:20
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 skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants