Skip to content

docs(changeset): correct two scope sentences in the pending service-analytics masked-field note - #20991

Closed
objectstack-fleet[bot] wants to merge 1 commit into
mainfrom
claude/issue-20935-changeset-wording
Closed

objectstack-fleet[bot] wants to merge 1 commit into
mainfrom
claude/issue-20935-changeset-wording

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #20935
Clause-②: no

A one-file follow-up to PR #20955, which landed as 83480c6a2f. It corrects two sentences in the release note .changeset/20935-analytics-masked-field-not-queryable.md, which is still pending on main. The order is the dispatch note 5922157430 on #20935, and the "Changeset wording" bullet of the review adoption 5921385686 on PR #20955. Release PR #20639 consumes pending changesets when it merges, so this needs to land first. No code, test or other file changes.

What changed under the note

1. The BREAKING banner was too narrow.

Before:

BREAKING for analytics queries on a SQL deployment that group, aggregate, filter or sort by a field the caller may only see masked.

After:

BREAKING for analytics queries that group, aggregate, filter or sort by a field the caller may only see masked: on a SQL deployment, and on POST /api/v1/analytics/sql whichever strategy serves the cube.

Why, read on 05be352596:

2. "What is not affected" was too broad.

Before:

What is not affected. A caller who holds the capability that lifts a field's masking rule queries the field as before. A system context is unaffected. A query that names no masked field answers as before.

After:

What is not affected. Unless the security service predates getQueryableFields or answers "no answer" (see New hook): a caller who holds the capability that lifts a field's masking rule queries the field as before, a system context is unaffected, and a query that names no masked field answers as before.

Why:

  • The getQueryableFields bridge in packages/services/service-analytics/src/plugin.ts has a fallback. It applies when the service lacks the method or answers undefined.
  • The fallback takes the service's getReadableFields answer and removes every field whose declaration carries a maskingRule. It reads nothing about the caller.
  • getReadableFields answers the full field set for a system context (security-plugin.ts). So under the fallback a system caller loses those fields too.
  • field-read-admission.ts refuses any named field that either answer leaves out.
  • The test field-query-admission-gate.test.ts pins this. Its case "fails CLOSED for a security service that predates getQueryableFields: every field declaring a masking rule is not queryable" also asserts the system caller is refused.

When the service does answer, all three sentences hold as written: getQueryableFields returns the full set for a system context, and it keeps a field whose rule is lifted for the caller. The case "serves a caller the security service reports the rule lifted for" pins the second point.

Not changed: the summary line, the changeset's own Clause-②: yes (narrowing) line, the ADR-0087 disposition marker, the banner's BREAKING marker, and the "What changed", "New hook" and remedy paragraphs. Re-run on this head, the ADR-0087 gate's breakingDeclaration still reads all three signals from the file: BREAKING, bang and clause-②-narrowing. Its disposition reads not-required (no-migration-prescription).

Scope

  • One qualifier, three sentences. The correction to "What is not affected" is a single qualifier in front of all three of its sentences, so it also covers the capability-holder sentence. That is an in-place fix of the same defect: the same fallback makes that sentence too broad. The comment in plugin.ts says the fallback "over-refuses a caller the rule is lifted for — a system one included". The fix stays in the same file and adds nothing new to verify. A qualifier on the system sentence alone would have implied that the capability holder is unaffected even under the fallback.
  • Corrections only. Each new sentence is backed by the code or by the existing "New hook" paragraph.
  • Disclosure discipline holds. No request recipe, field spelling or returned value appears here.

Changeset gate: no skip-changeset, and Check Changeset stays red

This PR edits a pending changeset and adds none, so Check Changeset goes red by design:

  • The "Require a changeset" step fails. Route 0 of its own message describes this case.
  • check-empty-changeset.mjs refuses it as the DELIBERATE CORRECTION class.

Ruling D on #18375 says skip-changeset is never applied to a PR that edits an existing changeset, so no label is applied. Check Changeset is not a required context.

Confirmation requested in writing on this PR: the note .changeset/20935-analytics-masked-field-not-queryable.md from PR #20955 is corrected in two places:

  1. The BREAKING banner's scope now includes POST /api/v1/analytics/sql on either strategy.
  2. "What is not affected" now holds only where the security service answers getQueryableFields.

Clause-②: no was checked against scripts/pm/clause2-line.mjs. The value answers whether the change widens an accept set or adds public surface, and a wording edit to a pending note does neither, so the value is no. With no arm, the line declares no direction. no (narrowing) would declare a breaking change, and this PR makes none.

Verification at fa6f9f7133

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (exit 0) derived 19 commands from the one changed path. Each ran on this head:

  • 18 exit 0:
    • check-adr-0087-registration.mjs --base origin/main and --self-test
    • check-changeset-no-major.mjs --base origin/main and --self-test
    • check-closing-keyword-parity.mjs and --self-test
    • check-comment-mask-corpus.mjs
    • check-empty-changeset.mjs --self-test
    • pm/release-rehearsal-clone.mjs --self-test
    • check:changeset-gate-self-tests, check:driver-memory-census, check:gitlink-declared, check:nul-bytes, check:objectui-changeset, check:pm-changeset-deadline-census, check:published-files, check:refd-timer-probe, check:watch-hint-literal
  • 1 exit 1, as expected: check-empty-changeset.mjs --base origin/main refuses with the DELIBERATE CORRECTION class described above.
  • One extra roster gate: check-changeset-fixed.mjs (its roster lives under .changeset/) exited 0.
  • Reconciliation: dispatch-gates.mjs --ran with the recorded exit codes exited 0, reporting "19 derived, 19 run, 0 NOT-MEASURED, 0 UNRUN".
  • NOT MEASURED, by design: the type-check lanes and the package test suites. The diff touches no TypeScript and no package source.

Acceptance notes

  • The sibling notes have the same narrow scope. .changeset/20917-analytics-field-permission-gate.md and .changeset/20933-analytics-relationship-path-admission.md carry the same "on a SQL deployment" banner. They are not edited here, per the order, and are reported to the seat. In both cases the gate the change added sits in callCtx, which generateSql() shares. Before each change, that gate did not exist or did not cover the objects in question, and the ObjectQL echo never reached the engine. So the narrowing on the /analytics/sql echo reaches both strategies there too.
  • Wording noted, not changed (no new claims):
    • The banner does not mention that the fallback also refuses callers who see a field unmasked. The corrected "What is not affected" qualifier and "New hook" cover that.
    • "New hook" says "for every caller". Under the fallback, a reader that also answers "no answer" judges no field at all.

Generated by Claude Code

…rvice-analytics masked-field note

The BREAKING banner named only a SQL deployment, while the SQL echo
(POST /api/v1/analytics/sql) now refuses the same members whichever
strategy serves the cube: its admission runs in callCtx, ahead of the
strategy, and the ObjectQL strategy's echo renders without the engine.

"What is not affected" read as unconditional, while the plugin bridge's
fallback (a security service that predates getQueryableFields, or answers
"no answer") refuses every field declaring a maskingRule whoever the
caller is, a system context and a capability holder included. The list now
carries that condition and points at the New hook paragraph.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: fa6f9f71333e9a7d7733baea37a6311837bad73f
Local-runs: none

PR #20991 (Part of #20935; a one-file follow-up to PR #20955, merged as 83480c6a2f), read as the net diff against main (PR base 05be352596; origin/main at 7fa67dada3 carries the same note byte for byte: one file, .changeset/20935-analytics-masked-field-not-queryable.md, +6 / −4, status M), the PR body and file list, card #20935 with every comment on it (triage 5919588833, claim 5919731088, the first dev report 5921193653, ruling 5921222205, the landing note 5921730794, the dispatch note 5922157430, the follow-up dev report 5922335681), PR #20955 with its at-tier record and seat adoption 5921385686, scripts/check-empty-changeset.mjs, scripts/pm/clause2-line.mjs, and the check-runs on this head. The card's disclosure discipline holds in this record: no request recipe, field spelling or returned value from the private measurement; tests are cited by file, case name and line only.

① Derived judgments

Accept-set and public-surface changes the diff implies. None. The diff edits two sentences of a PENDING release note and no code, test, schema or generated artifact; no accept set moves and no public surface is added, removed or renamed by this PR. The behaviour the note describes landed on main in 83480c6a2f and is unchanged here. Right.

The two corrected sentences, each tested against the code on main (7fa67dada3):

  1. The BREAKING banner (note line 11), now: "BREAKING for analytics queries that group, aggregate, filter or sort by a field the caller may only see masked: on a SQL deployment, and on POST /api/v1/analytics/sql whichever strategy serves the cube." True.

    • POST /analytics/sql calls AnalyticsService.generateSql (packages/runtime/src/domains/analytics.ts:140-158). generateSql runs callCtx BEFORE resolveStrategy (packages/services/service-analytics/src/analytics-service.ts:2578-2582), and callCtx runs assertReadAdmitted then assertFieldsReadable (lines 1479-1491), the same admission queryIn runs (line 1807). assertFieldsReadable is the one call site that hands assertNamedFieldsReadable the queryable reader; the hidden predicate there refuses a named field that EITHER answer leaves out (field-read-admission.ts, the predicate in assertNamedFieldsReadable).
    • The ObjectQL strategy's echo renders the statement itself: ObjectQLStrategy.generateSql (strategies/objectql-strategy.ts:372-639) calls no engine method, so before fix(security,service-analytics)!: the security contract publishes which fields a caller may query on, and the analytics field gate refuses a masked field as a group or filter member (#20935) #20955 it printed a statement for a masked member rather than refusing it (at the pre-merge parent b1aee33f88, field-read-admission.ts names no queryable reader at all — zero hits). The native strategy compiles its own statement (strategies/native-sql-strategy.ts:412). So the narrowing on the echo reaches both strategies, and the previous wording "on a SQL deployment" was over-narrow for that door, exactly as the order 5921385686 said.
    • Pinned at the route level over the real SecurityPlugin, ObjectQL and SqlDriver, once per composition (native, objectql): packages/rest/src/analytics-masked-field-gate.test.ts, case "the cube read and the SQL echo refuse the member a masked field as a group or a filter member, before any strategy ran" (lines 308-320), which compares the echo's refusal with the engine's reference and asserts no strategy ran.
    • "On a SQL deployment" for /analytics/query and the dataset door stays right for the shipped compositions: on the ObjectQL strategy those two doors reached engine.aggregate / engine.find, whose guards already refused the member (the card's own measurement; the note's "What changed" paragraph says so and is unchanged). Disclosed residual, pre-existing and outside the order: a host-composed delegated in-memory face (InMemoryStrategy / FallbackDelegateStrategy over a MemoryAnalyticsService) is selected after callCtx and was noted NOT MEASURED on PR fix(security,service-analytics)!: the security contract publishes which fields a caller may query on, and the analytics field gate refuses a masked field as a group or filter member (#20935) #20955; packages/spec/liveness/analytics_cube.json records that no in-repo composition registers that service as the analytics service. The sentence is right for what is measured.
    • Moment: true on main today (the code is already there) and on main when this PR lands; the note is still pending, so the sentence ships only when the release PR consumes it.
  2. "What is not affected" (note lines 23-27), now: "Unless the security service predates getQueryableFields or answers "no answer" (see New hook): a caller who holds the capability that lifts a field's masking rule queries the field as before, a system context is unaffected, and a query that names no masked field answers as before." True.

    • The bridge fallback (packages/services/service-analytics/src/plugin.ts:840-864) applies when the registered service lacks getQueryableFields or that method answers undefined; it returns the service's getReadableFields answer less every field whose declaration carries a maskingRule (maskingRuleFields, lines 826-838, reads the data engine's object declarations only) and reads no caller property. The comment at lines 813-822 says it "over-refuses a caller the rule is lifted for — a system one included".
    • getReadableFields answers the full field set for a system context (packages/plugins/plugin-security/src/security-plugin.ts, resolveProjectionFieldMask: the context?.isSystem early answer), so under the fallback a system caller loses every rule-declaring field — the old unqualified "A system context is unaffected" was over-broad, as the order said. Pinned: packages/services/service-analytics/src/__tests__/field-query-admission-gate.test.ts, case "fails CLOSED for a security service that predates getQueryableFields: every field declaring a masking rule is not queryable" (lines 275-288, the system-context assertion at 286) and "fails CLOSED the same way when getQueryableFields answers "no answer" (undefined)" (lines 290-294).
    • When the service answers, all three sentences hold: getQueryableFields (security-plugin.ts, the #20935 method) returns the full set for a system context (same isSystem answer) and filters by the one query-guard map, which keeps a field whose rule is lifted for the caller; a query naming no masked field is judged by the read projection alone, which security(analytics): the native-SQL strategy answers a query naming a field the caller has no field-level read permission for, where the engine and the ObjectQL strategy refuse 403 #20917 already applied. Pins: the unit case "serves a caller the security service reports the rule lifted for — the bridge adds no rule of its own" (lines 268-273) for the bridge, and the route case "the control: the unmasker is answered for the same members exactly as the system caller is" (analytics-masked-field-gate.test.ts, both compositions) for the real security layer.
    • The one qualifier is right over all three sentences, not only the system one: under the fallback the capability holder is refused too (the comment above), and a query naming a rule-declaring field that is NOT masked for this caller is refused too, so "names no masked field answers as before" also needs the condition. A qualifier on the system sentence alone would have left the other two over-broad.
    • "see New hook" points at the unchanged paragraph that describes exactly this fallback. The corner where the service also answers "no answer" for getReadableFields returns undefined and judges no field (plugin.ts line 861); the qualifier withdraws a guarantee there rather than asserting a refusal, so it is not false.
    • Moment: true on main today and when this PR lands.

Nothing else in the note changed. The diff's two hunks touch line 11 and lines 23-25 only. Byte-identical before and after: the frontmatter ('@objectstack/service-analytics': minor), the summary line (fix(service-analytics)!: ...), the note's own Clause-②: yes (narrowing) line, the HTML-comment adr-0087 marker (not-required (no-migration-prescription) with its reason), the BREAKING marker (the banner still opens with the bold BREAKING token), and the "What changed", "New hook" and remedy paragraphs. Read against scripts/check-adr-0087-registration.mjs breakingDeclaration (lines 629-642): the head file still yields the three signals BREAKING (the bold-BREAKING test), bang (the !: summary) and clause-②-narrowing (the note's own arm, read through readClause2Line); the disposition marker is unchanged. Right. No new claim was added: the banner's added clause is the ordered scope correction, backed by the code and the route pin above; the qualifier is backed by the existing "New hook" paragraph.

Author-shown and AI-facing text, sentence by sentence (only what is false, unsourced, imprecise or over-broad is listed; everything else in the PR body, the commit message and the two corrected sentences tested true against the tree):

  • PR body, "Changeset gate" section: "check-empty-changeset.mjs refuses it as the DELIBERATE CORRECTION class." — Imprecise, twice. (a) The script's foreign-changeset rule refuses an M row and prints BOTH classes because, in its own words, "the diff shape cannot tell them apart" (check-empty-changeset.mjs header, the #17712 section, and FOREIGN_TWO_CLASS_LINES); which class applies is the author's reading, which is correct here. (b) In the Check Changeset job the step that runs this script never ran on this head: "Require a changeset (or the skip-changeset label)" fails first (pr-automation.yml:691-842; the check-run's annotation on this head is that step's route-0 text), and the later steps — the empty-frontmatter step, the ADR-0087 step and the no-major step (lines 892, 932, 1034) — carry only label conditions, so they are skipped after the failure; the workflow says so itself ("THIS step fails first and the steps after it are skipped"). The sentence is true of the dev's local run only. Not misleading for action (same red, same remedy), but the seat should read it as a local verdict.
  • PR body, "Verification at fa6f9f7133" — unsourced from this record's inputs; the nineteen local gate runs are the dev's and were not re-run here. Consistent with the code reading above; the gate verdicts are the check-runs below, and for the three changeset steps named in the previous bullet CI recorded no verdict on this head (skipped), so this record judges those by reading (② below).
  • PR body: "Release PR chore: version packages #20639 consumes pending changesets when it merges, so this needs to land first." — sourced only to the seat's own dispatch note 5922157430 and landing note 5921730794; chore: version packages #20639 is outside this record's inputs and was not read.
  • PR body: "Ruling D on [finding] the skip-changeset label suppresses the DELIBERATE-CORRECTION refusal whose own text says 「no label and no diff shape makes that safe」 — declared contract against enforced behaviour #18375 says skip-changeset is never applied to a PR that edits an existing changeset." — true as the workflow states it (pr-automation.yml:735-737). Note that check-empty-changeset.mjs names a different "ruling D" (on finding: random changeset filenames collide silently across parallel agents — a round overwrote a sibling PR's minor changeset and every gate stayed green #17712, fix(scripts): the foreign-changeset refusal prescribes restoring a release note the same PR made false — name the second class (ruling D on #17712) #18160: the two-class remedy); the PR body cites the right one for the label rule.
  • PR body: "Check Changeset is not a required context." — true: the seven required contexts (AGENTS.md, Multi-agent discipline §7) do not include it.
  • PR body, "The case 'serves a caller the security service reports the rule lifted for' pins the second point." — true of the bridge (a fake service answering the read projection); the real-security-layer pin for a capability holder is the route test's unmasker control, named above. Under-cited, not false.
  • Commit message: both paragraphs tested true (the callCtx ordering; the fallback's caller-blind refusal).
  • Disclosure discipline: the diff, the PR body, the commit message and the dev report carry no request recipe, field spelling or returned value from the private measurement; test cases are named, fixtures are synthetic.

② Semver level

③ Boundary flags

  • Dev open_questions[0] (who confirms the corrected note in writing on docs(changeset): correct two scope sentences in the pending service-analytics masked-field note #20991: the seat, or the maintainer): escalated to the maintainer. The gate's own text routes this class to a person — "get it confirmed there in writing", "what puts the decision in front of a person instead of routing around it" (pr-automation.yml route 0), "That is the existing human path" (check-empty-changeset.mjs reportForeign) — and the brief for this record says the same. The seat ordered the correction and reviewed it, which is not the confirmation the gate asks for. The diff is right either way; the landing waits on that written confirmation, not on this record.
  • Dev open_questions[1] (the sibling notes .changeset/20917-analytics-field-permission-gate.md and .changeset/20933-analytics-relationship-path-admission.md carry the same "on a SQL deployment" banner; correct them before the release PR consumes them?): escalated to the seat; option A is the right shape — one one-file follow-up per note, under each card's own claim, while the notes are pending. Verified on main: both banners read as the dev quotes them. The dev's evidence direction holds for security(analytics): the native-SQL strategy answers a query naming a field the caller has no field-level read permission for, where the engine and the ObjectQL strategy refuse 403 #20917: at 1571aedce5's parent 95555e71cf no non-test file under service-analytics/src names getReadableFields or assertFieldsReadable (exit 1, zero hits); at 1571aedce5 three files do; and the ObjectQL echo has never reached the engine, so that gate also moved the echo from printed to refused on both strategies. security(analytics): on the native-SQL strategy an inferred cube's relationship path reads the related object without that object's read admission or its row scope #20933 landed as 5f6b63a6fd with parent 83480c6a2f, as the dev states. Not this PR's to edit, per the order.
  • Deviations: the dev report declares none. Two readings were checked. (a) The named "conflict" (keep the banner "as it is" while correcting line 11, which is the banner): the dev kept the BREAKING marker and corrected the scope words — the only reading under which both the order's "cuts or corrections" and its "no new claims" hold; right. (b) The qualifier covers three sentences where the order named one ("A system context is unaffected"): a widening of the edit's reach, correct in substance (① item 2), in the same file, adding no claim and no verification surface; accepted as an in-place fix of the same defect.
  • Dev out_of_scope_findings[0] (the sibling banners): the same as open_questions[1]; escalated to the seat.
  • Dev out_of_scope_findings[1] (two wording observations in the 20935 note, left untouched: the banner does not say the fallback also refuses callers who see the field unmasked; "New hook" says "for every caller" while a reader whose read projection is also "no answer" judges no field): answered — text precision, not a defect. The corrected qualifier and the "New hook" paragraph cover the first; the second is a per-object corner (plugin.ts line 861), not a per-caller one, so "for every caller" is not false. Both sentences predate this PR and the order did not name them.
  • Dev out_of_scope_findings[2] (check-adr-0087-registration.mjs prints "N non-breaking changeset(s) seen" from its skipped list, which can hold inherited-breaking files): the print line exists (check-adr-0087-registration.mjs:7224); output wording only, no carrier, no verdict moves. Noted.
  • Claim parity: the card's only Claim: line (5919731088) names the first branch; this branch is named by the seat's dispatch note 5922157430, from the same session that holds the claim. The PR carries no closing keyword ("Part of security(analytics): a field the caller may only see masked is answered unmasked as a grouped or filtered member on the native-SQL strategy; the published field reader has no masked-for-this-caller answer #20935"), so The card this PR closes must claim this branch and Part-of PR must not also close its card both apply trivially and are green. Answered.
  • File surface: one file, inside the dispatch's declared surface. Not governed (Governed Surface Queue Guard green). Draft, assigned to the claim's account, head and base in the same repository. The commit's trailer pair is model-free.
  • Required contexts on this head: Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate and Governed Surface Queue Guard are success; Build Core and Temporal Conformance (live PG + MySQL) are skipped (path-filtered on a changeset-only diff; the filter job is success). Stated for the seat's landing read; not this record's to judge.

Check-runs on fa6f9f7133 (26 runs, 26 names after dedupe by newest started_at; none running). failure (1): Check Changeset — by design, the DELIBERATE CORRECTION class; its annotation is the "Require a changeset" step's route-0 text, and the empty-frontmatter, ADR-0087 and no-major steps did not run. success (17): Auto Label, Check Documentation Links, Check PR Size, Dogfood Regression Gate, Governed Surface Queue Guard, Lint & Repo Gates, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Test Core, The card this PR closes must claim this branch, Type Check · consumer gates, Type Check · debt ledger, Type Check · source gates, Type Check · workspace, TypeScript Type Check, filter. skipped (8): Build Core, Build Docs, Console Pin Gate, Dogfood Regression Gate (matrix shards), Dogfood Verify CLI, Packed-tarball smoke (opt-in), Temporal Conformance (live PG + MySQL), Test Core (matrix shards) — conditional jobs on a diff that touches no source.

Implemented-by: claude/issue-20935-changeset-wording
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1

VERDICT: PASS

Adopted and posted by domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1) · 2026-10-01T00:58Z · rendered by the seat's at-tier review subagent on this head. The seat read its served tier family from the subagent transcript before posting. ⚠️ The disclosure discipline holds.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

os-decision-facets

决策请求:确认更正一条尚未发版的安全发布说明(PR #20991)· 2026-10-01T00:59Z

domain:spec seat 5(session_01Sfe5YjBLwB9J3y8fvm2xq1)。⚠️ 披露纪律:只按类别描述,不含任何请求、字段名或返回值。

一句话问题: p0 安全修复 #20935 已合并,它附带的发布说明还没发版。说明里有两句写得不准,会让升级的客户误判自己受不受影响。仓库检查规定:改一条待发的发布说明,要由人确认(scripts/check-empty-changeset.mjs:605-612:「关于发版的决定……放到人面前」)。

背景与前提(每条可复核):

  • 说明文件:.changeset/20935-analytics-masked-field-not-queryable.md,仍在 main 上待发。发版 PR chore: version packages #20639 合并时会把它写进 CHANGELOG。
  • 改动只有一个文件、两处(+6/-4),档位审查已通过(记录见本 PR)。
    • 其一,适用范围: 原文说只影响「SQL 部署」。实际上,/analytics/sql 的 SQL 预览在两种执行方式下都会拒绝(由 packages/rest/src/analytics-masked-field-gate.test.ts 钉住)。
    • 其二,例外条件: 原文说「系统上下文不受影响」。但在旧版安全服务的兜底路径上,系统调用和持权调用一样会被拒(由 field-query-admission-gate.test.ts 钉住)。
  • 这份说明的 BREAKING 标记、Clause-② 行和 ADR-0087 处置都不变。

选项 × 代价:

业务含义: A 等于发布说明和产品实际行为一致;B 等于发版说明里留下一句与行为不符的安全说明。

四棱:

  • ① 长远:安全类 BREAKING 说明必须写准适用范围,发布说明是客户判断升级影响的唯一依据。
  • ② 拉动:今天就会撞上。用 SQL 预览、或接了旧版安全服务的部署,升级后会被拒绝,原说明没告诉他们。
  • ③ 防 AI 犯错:说窄了是静默误导(读者以为不受影响);写准是响亮提示。
  • ④ 不扩散:只改一份待发说明的两句,不新增任何声明。

Prior rulings read: DELIBERATE CORRECTION|deliberate correction over scripts/check-empty-changeset.mjs → ruling D on #17712(该类走人工确认路径);thread: none

推荐:A。 只看①选 A;②③④ 是否翻转:否。回退项:B。置信缺口:两句的正确性来自代码阅读加已有测试,未在运行中的服务上实测。

裁后执行: 维护者答 A 后,席位把本 PR 转为 ready 并放入合并队列(Check Changeset 按设计保持红,本评论即其记录);合并后收尾。答 B 则关掉本 PR。

你要做的: 回一个字母,A 或 B。


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

The maintainer's answer: B, do not correct the note; this PR is closed unmerged

domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1) · 2026-10-01.

  • The maintainer answered 「20991 20977 都不改」 in the seat's chat, on 2026-10-01, to the decision 5922571033: B.
  • .changeset/20935-analytics-masked-field-not-queryable.md therefore ships as written. The review record 5922567245 stays as the reading of what the two sentences say.
  • In this same act the seat removes needs-user-decision and closes this PR without merging. The branch is left in place.

Generated by Claude Code

@objectstack-fleet objectstack-fleet Bot closed this Oct 1, 2026
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… to the SQL echo on either strategy, and anchor the nested-relation note to its measured base (objectstack-ai#21012)

Part of objectstack-ai#20917
Clause-②: no
Part of objectstack-ai#20933 · Part of objectstack-ai#20887

A follow-up on three landed cards. It corrects one sentence in each of
three release notes of `@objectstack/service-analytics` that are still
pending on `main`, before release PR objectstack-ai#20639 consumes them. The order is
this lane's dispatch note on objectstack-ai#20917. The reasons are the `domain:spec`
seat's pointer `5922364600` on objectstack-ai#6021 (two banners) and the named
follow-up in section ② of the delta review `5922352898` on PR objectstack-ai#20916
(one paragraph). PR objectstack-ai#20991 is the precedent: it makes the same banner
correction to objectstack-ai#20935's note, and that note is not touched here. No code,
test or other file changes.

## The three sentences

| Note | Before | After |
|:---|:---|:---|
| `.changeset/20917-analytics-field-permission-gate.md`, the BREAKING
banner | "BREAKING for analytics queries on a SQL deployment that read a
field the caller may not read." | "BREAKING for analytics queries that
read a field the caller may not read: on a SQL deployment, and on `POST
/api/v1/analytics/sql` whichever strategy serves the cube." |
| `.changeset/20933-analytics-relationship-path-admission.md`, the
BREAKING banner | "BREAKING for analytics queries on a SQL deployment
that read a related object through a relationship path the cube does not
declare." | "BREAKING for analytics queries that read a related object
through a relationship path the cube does not declare: on a SQL
deployment, and on `POST /api/v1/analytics/sql` whichever strategy
serves the cube." |
| `.changeset/20887-analytics-nested-relation-engine-answer.md`, the
first sentence of **Why** | "Measured on the base over one fixture with
the real security layer (…)." | "Measured on the base before the
field-level gate (objectstack-ai#20917) and the relationship-path admission (objectstack-ai#20933)
landed, over one fixture with the real security layer (…)." |

The parenthesis in the third row is unchanged and elided here. Both
banners keep their bold. Everything else in the three files is
byte-identical: front matter, summary line, `Clause-②` line, ADR-0087
disposition marker and every other paragraph. Each file's diff is one
line (+1/−1).

## The readings behind each sentence

### Banner of the objectstack-ai#20917 note

**Before, by source, at `95555e71` (the parent of objectstack-ai#20931's landing
`1571aedc`):**

-
`packages/services/service-analytics/src/analytics-service.ts:2305-2333`:
`generateSql()` runs `callCtx` (2329), resolves a strategy (2330) and
returns that strategy's `generateSql` (2333).
- `analytics-service.ts:1283-1327`: `callCtx` runs the object-level
admission (1308) and the read-scope pre-pass. It has no field-level
gate. No non-test source in the package names `getReadableFields` at
that commit (`git grep` exit 1). The control is the landing `1571aedc`,
where it is named in three files.
- `strategies/objectql-strategy.ts:372-638`: the ObjectQL strategy's
`generateSql` renders the statement from the cube definition and returns
it (638). It makes no engine call, so it never reaches the engine's
field guards. That strategy's `execute()` reaches `engine.aggregate`,
which is why the query door already refused on it.
- So for a field the caller may not read, the echo printed a statement
on the ObjectQL strategy as well.

**After, on `main` at `a5bce40888`:**

- `analytics-service.ts:1453-1515`: `callCtx` runs the field-level gate
(1487) after the object admission (1481).
- `generateSql()` calls `callCtx` before it resolves a strategy (2580).

### Banner of the objectstack-ai#20933 note

**Before, by source, at `83480c6a` (the parent of objectstack-ai#20962's landing
`5f6b63a6`):**

- `analytics-service.ts:1452-1507`: `callCtx` admits objects over
`queryObjects` (1477). That set is `cubeObjects` (1584-1588, 1596-1608):
the base object and the declared joins only. An object reached through
an undeclared relationship path is not in it.
- The field-level gate (1483) asks the security service's
`getReadableFields`. That answer is field-level only and never asks
about object-level read
(`packages/plugins/plugin-security/src/security-plugin.ts:5410-5412`,
`computeReadableFields` 5440-5461 over `resolveProjectionFieldMask` 5517
ff.). Object-level read is the separate `canReadObject` (5641). So the
field gate does not refuse a readable field on an unreadable related
object.
- `objectql-strategy.ts` is byte-identical at `95555e71`, `83480c6a` and
`5f6b63a6` (`git diff --quiet`, exit 0 for both ranges).
- Its `generateSql` passes a one-hop cross-object dimension through
`planCrossObject` (895 ff.). That function throws only for the
out-of-envelope shapes: a cross-object time dimension, measure or
filter, a multi-hop dimension, a non-recombinable measure. The one-hop
dimension renders a LEFT JOIN (461-466), and the statement is returned
(638).
- So on the ObjectQL strategy the echo printed a statement for a one-hop
dimension through a related object the caller may not read.

**After, on `main` at `a5bce40888`:**

- `queryObjects` (1607-1616) adds every object a named member reads.
- The admission at 1481 covers that set before `generateSql()` resolves
a strategy (2580).

### Measured after, on both strategies

The measurement was a scratch probe at `a5bce40888`, copied into
`packages/rest/src` for one run and removed by an EXIT trap. It was
never committed; `git status --porcelain` was empty afterwards.

- **Build:** the probe's dependency closure was built under
`os-verify-lock.sh` first: `turbo run build --concurrency=1` over
`@objectstack/service-analytics...`, `@objectstack/plugin-security...`,
`@objectstack/objectql...` and `@objectstack/driver-sql...`. That is 19
tasks, `VERDICT command-exit 0`.
- **Run:** `pnpm --filter @objectstack/rest exec vitest run
--maxWorkers=2` on the one file gave 1 file / 2 tests passed, `VERDICT
command-exit 0`.
- **Fixture:** the composition is the shipped one, with the real
security layer: `SecurityPlugin` over `ObjectQL` on `SqlDriver`
(SQLite), and `AnalyticsServicePlugin` over the same engine. There are
two compositions: `native` (the plugin's own capabilities) and
`objectql` (narrowed to the engine-aggregate path).
- **What was asked:** each query body first passed the door's own body
schema (`AnalyticsQueryRequestSchema`). It then went to `generateSql`,
the call `POST /api/v1/analytics/sql` makes
(`packages/runtime/src/domains/analytics.ts:141-159`). The thrown
`status` is the HTTP status
(`packages/runtime/src/dispatcher-plugin.ts:646-649`).

| Question, inferred cube | Member caller, `native` | Member caller,
`objectql` | System caller, either |
|:---|:---|:---|:---|
| A field the member may not read, grouped | `403 PERMISSION_DENIED` |
`403 PERMISSION_DENIED` | statement printed, by the composition's
strategy |
| The same field, filtered | `403 PERMISSION_DENIED` | `403
PERMISSION_DENIED` | statement printed |
| A one-hop dimension through a related object the member may not read,
path not declared | `403 PERMISSION_DENIED` | `403 PERMISSION_DENIED` |
statement printed |
| Control: a readable field | statement printed (`NativeSQLStrategy`) |
statement printed (`ObjectQLStrategy`) | statement printed |
| Control: a readable related object, path not declared | statement
printed (`NativeSQLStrategy`) | statement printed (`ObjectQLStrategy`) |
statement printed |

Before and after both hold for both banners. So each banner is scoped
the way PR objectstack-ai#20991 scoped objectstack-ai#20935's. The note's own pins in
`packages/rest/src/analytics-field-permission-gate.test.ts` and
`analytics-relationship-path-admission.test.ts` assert the same echo
refusals on both compositions. The probe adds the system-caller and
readable controls, and it ran the bodies through the door's schema.

### The objectstack-ai#20887 **Why** paragraph

The dev measured on the branch point of PR objectstack-ai#20916, `00a92e18da`. That is
the parent of its first commit, `5687276296`.

**The base predates both gates.** `git merge-base --is-ancestor
00a92e1 X` exits 0 for X = `95555e71`, `1571aedc`, `83480c6a` and
`5f6b63a6`, so both landings descend from it. The reverse, `1571aedc`
against `00a92e18da`, exits 1. Its control leg, `00a92e18da~200` against
`00a92e18da`, exits 0, and the repository is not shallow.

**Clause one, "answered rows for a condition on a field the caller
cannot read", held on that base:**

- No non-test source in the package names `getReadableFields` (`git
grep` exit 1; control: six hits in `analytics-service.ts` on `main`).
- `callCtx` (`analytics-service.ts:1283` ff. at `00a92e18da`) runs only
the object admission (1308) and the read-scope pre-pass.
- The base filter normalizer flattened the form to a dotted member
(`strategies/filter-normalizer.ts:1296-1299`). The native strategy
joined the declared include.
- The dev's first report (`5916988260` on objectstack-ai#20887) records the measured
rows.

**Clause two, "without the declared join it named a table that does not
exist (500)", held on that base:**

- `strategies/native-sql-strategy.ts:771-783` falls back to the
relationship name as the joined table when no join is declared.
- The join allowlist applies to a compiled dataset only. An inferred
cube has none (`analytics-service.ts:1266-1268`;
`native-sql-strategy.ts:575-576`).
- The object admission covered the base object and the declared joins
only (`analytics-service.ts:1404`, `1416` ff.). So nothing refused
before the statement ran.

The paragraph is anchored to that base in the delta review's own words;
the clauses are kept.

### The ADR-0087 gate's reading

`breakingDeclaration` and `readDisposition` were re-run on each note at
`a5bce40888` and at this head. All three notes read the same on both:

- `breaking: true`;
- the signals `BREAKING`, `bang` and `clause-②-narrowing`;
- the disposition `not-required (no-migration-prescription)`.

The gate itself counts the three as inherited, not introduced: it skips
a changeset already breaking at the branch point.

## Changeset gate: no `skip-changeset`, and `Check Changeset` stays red

This PR edits three pending changesets and adds none, so `Check
Changeset` goes red by design. `check-empty-changeset.mjs --base
origin/main` exits 1 and refuses all three files as the DELIBERATE
CORRECTION class. Ruling D on objectstack-ai#18375 says `skip-changeset` is never
applied to a PR that edits an existing changeset, so no label is
applied. `Check Changeset` is not a required context.

**Confirmation:** this lane's at-tier contract review record on this
PR's head judges each rewritten sentence above. The sentences are:

1. the objectstack-ai#20917 note's banner, now scoped to include `POST
/api/v1/analytics/sql` on either strategy;
2. the objectstack-ai#20933 note's banner, scoped the same way;
3. the first sentence of the objectstack-ai#20887 note's **Why**, now anchored to the
base before the field-level gate and the relationship-path admission
landed.

`Clause-②: no` was checked against `scripts/pm/clause2-line.mjs`. A
wording edit to pending notes widens no accept set and adds no public
surface, so the value is `no`. With no arm, the line declares no
direction.

## Verification at `56fc0e77e3`

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (exit 0) derived 19 commands from the three
changed paths. Each ran on this head, with its exit code captured before
any pipe.

- **18 exit 0:**
- `check-adr-0087-registration.mjs --base origin/main` and `--self-test`
  - `check-changeset-no-major.mjs --base origin/main` and `--self-test`
  - `check-closing-keyword-parity.mjs` and `--self-test`
  - `check-comment-mask-corpus.mjs`
  - `check-empty-changeset.mjs --self-test`
  - `pm/release-rehearsal-clone.mjs --self-test`
- `check:changeset-gate-self-tests`, `check:driver-memory-census`,
`check:gitlink-declared`, `check:nul-bytes`, `check:objectui-changeset`,
`check:pm-changeset-deadline-census`, `check:published-files`,
`check:refd-timer-probe`, `check:watch-hint-literal`
- **1 exit 1, by design:** `check-empty-changeset.mjs --base
origin/main`, the DELIBERATE CORRECTION class above.
- **Roster family:** `check-changeset-fixed.mjs` (its roster lives under
`.changeset/`) exited 0.
- **Reconciliation:** `dispatch-gates.mjs --ran` with the recorded exit
codes exited 0. It read "19 derived, 19 run, 0 NOT-MEASURED, 0 UNRUN".
- **Main moved:** `origin/main` gained `f8178ffece` after the branch
point. It touches none of the three notes, and all three are still
present there.
- **NOT MEASURED, by design:** the type-check lanes and the package test
suites. The diff touches no TypeScript and no package source.

## Acceptance notes

- **Wording noted, not changed (corrections only, no new claims).**
- The objectstack-ai#20917 note's **What changed** ends: "the ObjectQL strategy and
the data API already refused them". Its ADR-0087 marker says the engine
"already refuses" such queries "on the ObjectQL strategy". Both hold for
the query and dataset doors, not for the SQL echo, which printed on that
strategy (reading above). The corrected banner carries the echo's scope.
- The objectstack-ai#20933 note's **Refusals that change form** says: "On the ObjectQL
strategy a related object the caller may not read was already refused".
That holds for the query door. On the echo, a one-hop dimension through
such an object printed (reading above). The corrected banner carries the
echo's scope.
- **Where the probe measured.** It measured the service call the door
relays, not the mounted HTTP route: identity resolution needs an auth
composition the probe did not boot. The relay and the status mapping are
cited at file:line above.
- **Not in this PR:** objectstack-ai#20935's note. PR objectstack-ai#20991 holds it.
- **Disclosure discipline holds.** No request recipe, field spelling or
returned value appears here.

## Patch rounds (the seat's append from the dev's report on objectstack-ai#20917; the
dev writes a body only once)

### Patch round 1

At `db64c37690`, on the seat's note `5923016038` on objectstack-ai#20917, which takes
the dev's class (a) finding into this PR. Two sentences beside the
corrected banners are qualified to the doors they hold for. Nothing else
changes: each is still one sentence, rewrapped in place, and both
ADR-0087 disposition markers are byte-identical.

| Note | Before | After |
|:---|:---|:---|
| `.changeset/20917-analytics-field-permission-gate.md`, **What
changed**, last sentence | "The native-SQL strategy, the one a SQL
driver serves first, answered such queries; the ObjectQL strategy and
the data API already refused them." | "The native-SQL strategy, the one
a SQL driver serves first, answered such queries; the ObjectQL strategy
already refused them on `POST /api/v1/analytics/query` and `POST
/api/v1/analytics/dataset/query`, as the data API did, but printed the
statement on `POST /api/v1/analytics/sql`." |
| `.changeset/20933-analytics-relationship-path-admission.md`,
**Refusals that change form**, first sentence | "On the ObjectQL
strategy a related object the caller may not read was already refused;
it now answers the analytics door's refusal rather than the engine's,
the same one a declared join gets." | "On the ObjectQL strategy a
related object the caller may not read was already refused on `POST
/api/v1/analytics/query` and `POST /api/v1/analytics/dataset/query`,
though `POST /api/v1/analytics/sql` printed the statement; on those two
doors it now answers the analytics door's refusal rather than the
engine's, the same one a declared join gets." |

**Readings:**

- **objectstack-ai#20917's sentence, at `95555e71`.** On the query door, the ObjectQL
strategy's `execute()` reaches the engine (`objectql-strategy.ts:301`)
before it renders its own echo (350-354). The dataset door runs the same
`execute()`, so both refused through the engine. The SQL echo's
`generateSql` (372-638) renders with no engine call, and `callCtx` had
no field gate (`analytics-service.ts:1283-1327`). So `POST
/api/v1/analytics/sql` printed the statement.
- **objectstack-ai#20933's sentence, at `83480c6a`.** On the query and dataset doors,
a one-hop dimension through a related object goes through
`executeCrossObject`. Its `resolveFkAttr` (1176 ff.) reads that object
through the engine as the caller (1211-1215). The echo renders the join
(461-466) and returns the statement (638), with no engine call, and the
object was outside the admitted set (`analytics-service.ts:1584-1608`).
After the change, on `main`, the echo answers `403 PERMISSION_DENIED` on
both strategies (round 0's probe).

**ADR-0087 reading at `db64c37690`:** for both notes,
`breakingDeclaration` reads `breaking: true` with the signals
`BREAKING`, `bang` and `clause-②-narrowing`, and `readDisposition` reads
`not-required (no-migration-prescription)`. Both are unchanged from
`a5bce40888`, and the objectstack-ai#20887 note reads the same.

**Gates at `db64c37690`:**
- `dispatch-gates.mjs --commands` derived the same 19 commands. 18
exited 0. `check-empty-changeset.mjs --base origin/main` exited 1 by
design: the DELIBERATE CORRECTION class, with all three notes named.
- The roster gate `check-changeset-fixed.mjs` exited 0.
- `--ran`: 19 derived, 19 run, 0 not measured.
- The derivation was repeated at `origin/main` `8055ff2279`, which had
changed one roster file, and gave the same 19 commands. None of the
three notes changed on `main` over that range.

**Acceptance note, wording kept:** both ADR-0087 disposition markers are
HTML comments, and they stay byte-identical. The objectstack-ai#20917 marker still
says the engine "already refuses" such queries "on the ObjectQL
strategy". Like the two sentences above, that wording holds for the
query and dataset doors, not for the SQL echo.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…the error-code ledger lists its two refuse() codes (objectstack-ai#21106) (objectstack-ai#21150)

Fixes objectstack-ai#21106
Clause-②: yes

Three release-text follow-ups from the at-tier contract review of PR
objectstack-ai#21084 (comment `5926057594`), all due before Version Packages objectstack-ai#20639
next picks up PR objectstack-ai#21084. Each sentence was checked against the code at
PR objectstack-ai#21084's merge commit `8368f1c0`. The target files are byte-identical
between `8368f1c0` and this branch's base (`git diff --stat` printed
nothing). No package source changes.

1. **A deliberate correction of the stage-① pending note.**
`.changeset/20281-connector-sync-moved-to-mapping.md` said
`connectorSource` was "declared, not yet executed" and that "Nothing
executes it in this release". The stage-② note
`.changeset/20919-spec-connector-source-live.md` says the executor reads
it. Both compile into the same `@objectstack/spec` CHANGELOG version.
2. **The "provenance, not identity" ledger sentence** for this release's
`ERROR_CODE_LEDGER` face changes, in a new spec note,
`.changeset/21106-error-code-ledger-provenance-rows.md`.
3. **The two `@objectstack/service-automation` provenance rows**,
`MAPPING_NOT_FOUND` and `UNSUPPORTED_TRANSFORM`, in
`packages/spec/src/api/error-code-ledger.zod.ts`. A hand pin in the
provenance gate's own test,
`packages/spec/scripts/check-error-code-provenance.test.ts`, guards
them.

## 1 · The stage-① note correction

| | Before | After |
|:---|:---|:---|
| Lead label | "**Added (declared, not yet executed):**" | "**Added:**"
|
| Last sentence of that paragraph | "Nothing executes it in this
release, and `os validate` / `os build` warn when it is authored." |
"The connector sync executor, `@objectstack/service-automation`'s
`pullConnectorSource` (objectstack-ai#20919), reads it; nothing schedules a pull until
the `job` stage lands." |

Everything else in the file is byte-identical: the front matter, the
summary line, the BREAKING banner, the FROM → TO table and the
retirement kit. The diff is +4 / −3 lines in one paragraph.

**Readings at `8368f1c0`:**

- **"reads it".**
`packages/services/service-automation/src/connector-pull.ts`
`pullConnectorSource` (:209) reads the mapping through `getMetaItem` and
its `connectorSource`. It makes one action call and writes through
`runImport`. The liveness rows say the same:
`packages/spec/liveness/mapping.json`, `connectorSource` `live`,
evidence `connector-pull.ts#pullConnectorSource`.
- **"nothing schedules a pull until the `job` stage lands".** Outside
tests, `pullConnectorSource` has three places in its own package: the
definition, the index re-export and the plugin method (`plugin.ts:621`).
No package source calls the plugin method.
- **Why the review's suggested clause "`os validate` / `os build` warn
when it is authored" is NOT kept.** It is false at `8368f1c0`, measured:
- The ledger row is `live` with `authorWarn: true`.
`lintLivenessProperties`'s `describe()`
(`packages/lint/src/lint-liveness-properties.ts`) has no `live` branch.
It throws its sentinel for that pair, by design: its own header calls
the pair "a ledger authoring mistake".
- Through lint's source, against the built spec:
`runAuthoringRules('validate', …)`, the call `os validate` makes
(`packages/cli/src/commands/validate.ts:522`), THREW
`lintLivenessProperties: ledger entry has unrecognised status "live"`
for a stack whose `mappings[]` carries `connectorSource`. The control
without `connectorSource` gave 0 findings.
- The runtime metadata-write door (`runRuntimeAuthoringRules`) answered
one `authoring-rule-threw` advisory in place of the liveness warning.
- Read at `99398542b` and again at this head.
`lint-liveness-properties.ts`, `authoring-rules.ts`, `runtime-gate.ts`
and `liveness/mapping.json` have no diff between `8368f1c0` and
`99398542b`. The CLI process itself was not run: its closure was not
built here.
- So the corrected note claims no warning. The defect is reported to the
seat as a separate finding below and not changed here.

## 2 · The ledger sentence, and the counts behind it

Measured on `ERROR_CODE_LEDGER` at `8368f1c0` against its parent
`2742e537`, comments stripped:

| Key | Parent | `8368f1c0` | Change |
|:---|---:|---:|:---|
| `@objectstack/rest` | 83 | 76 | −7: `AMBIGUOUS_MATCH`,
`BLANK_MATCH_KEY`, `CONCURRENT_UPDATE`, `ERR_DATASOURCE_UNAVAILABLE`,
`NO_MATCH`, `SUMMARY_RECOMPUTE_FAILED`, `UNIQUE_VIOLATION` |
| `@objectstack/core` | 12 | 17 | +5: `AMBIGUOUS_MATCH`,
`BLANK_MATCH_KEY`, `NO_MATCH`, `SUMMARY_RECOMPUTE_FAILED`,
`UNSUPPORTED_TRANSFORM` |
| `@objectstack/types` | absent | 3 | new key: `CONCURRENT_UPDATE`,
`ERR_DATASOURCE_UNAVAILABLE`, `UNIQUE_VIOLATION` |
| owner keys | 30 | 31 | |
| union (`ErrorCode`) | 280 | 280 | none added, none removed |

**The card and the review say rest lost −8. The measurement is −7.**
Eight codes moved (5 to core and 3 to types), but rest kept
`UNSUPPORTED_TRANSFORM`, which `resolveNamedMapping` still stamps.
Literal counts in `packages/rest/src` non-test source at `8368f1c0`: 0
for each of the seven, and 1 for `UNSUPPORTED_TRANSFORM`. The note says
seven.

The sentence follows the objectstack-ai#20206 (`5f9d7d78`) and objectstack-ai#19441 (`3f9e2eaa`)
paragraphs. It names both steps of this release's face change, the
objectstack-ai#20919 move and this PR's two rows, and says the union, the wire and the
HTTP answers are unchanged.

## 3 · The two rows, and why a pin rather than a wider gate

- **Stamp sites at `8368f1c0`** (unchanged since).
`connector-pull.ts:234` `refuse('MAPPING_NOT_FOUND', 404,
'mapping_not_found', …)` and `:303-304` `refuse('UNSUPPORTED_TRANSFORM',
400, 'unsupported_transform', …)`. Both go onto
`ConnectorPullError.code`, and the class is exported from the package
index.
- **Both codes are registered extension codes.** They are listed under
`@objectstack/rest`, and `UNSUPPORTED_TRANSFORM` under
`@objectstack/core` too. Neither is in `errors.zod.ts`. The executor's
other five codes are standard-catalog members and owe no row:
`VALIDATION_ERROR`, `EXTERNAL_SERVICE_ERROR`, `INTEGRATION_ERROR`,
`SERVICE_UNAVAILABLE`, `INVALID_FIELD`.
- **Placement.** The rows go in the key's existing ASCII order:
`MAPPING_NOT_FOUND` after `INVALID_SIGNAL`, and `UNSUPPORTED_TRANSFORM`
last. One comment records the stamp sites, the statuses and the
reachability reading: no HTTP door on this tree, so the thrown value is
the boundary. No other package's rows were touched.
- **Not widened.** `check:error-code-provenance`'s header declares it
blind to a helper indirection (a `makeError(code, …)` call site). It
also says "Widening is a gate-population change with an unmeasured blast
radius — its own card, never a rider". So this PR keeps the gate's
patterns and pins the two rows by hand instead.
- **Measured for the seat, read-only.** The `refuse('CODE', …)`
call-site form stamps a registered code at 7 sites in 3 packages: core
`artifact-packages.ts` ×4, runtime `artifact-collections.ts` ×1, and
these two. Before this PR, these two were the only unlisted ones; after
it, none is unlisted.
- **The pin.** A new block in the gate's test file has two halves. One
pins the blind spot itself: `scanSourceText` finds no site in a
`refuse('X', …)` call. The other asserts that
`ERROR_CODE_LEDGER['@objectstack/service-automation']` lists each code.
It reads the ledger module inside `packages/spec`, so the suite still
reads nothing outside its package.
- **Ablation, two legs** through `scripts/ablation-replace.mjs` in WRAP
mode, run from the committed state at `c227eb366`:
- Removing the `MAPPING_NOT_FOUND` row gave `1 failed | 16 passed (17)`:
"expected [ …(9) ] to include 'MAPPING_NOT_FOUND'".
- Removing the `UNSUPPORTED_TRANSFORM` row gave `1 failed | 16 passed
(17)`, on that code's case.
- Each leg restored the file (blob `229345964e13` = HEAD, `git diff
HEAD` empty).
- A first attempt at leg one was refused by the tool before any test
ran: its replacement text already existed in the file. The anchor was
changed and the leg re-run. That first attempt is not counted as a run.

## Changeset gate: no `skip-changeset`, and `Check Changeset` stays red
by design

This PR edits a pending changeset that it did not add. `node
scripts/check-empty-changeset.mjs --base origin/main` exits 1 and
refuses `.changeset/20281-connector-sync-moved-to-mapping.md` as the
DELIBERATE CORRECTION class. The precedents are PR objectstack-ai#20991 and PR objectstack-ai#21012.
Ruling D on objectstack-ai#18375 says `skip-changeset` is never applied to a PR that
edits an existing changeset, so no label is applied. `Check Changeset`
is not a required context.

**Confirmation requested in writing on this PR:** the stage-① note's
`connectorSource` paragraph now says the executor reads the binding and
nothing schedules a pull yet. It no longer says nothing executes it or
that `os validate` / `os build` warn.

## `Clause-②: yes`, not the claim's `no`

The claim (`5926939917`) and the dispatch say `patch` with `Clause-②:
no`. The dispatch also says not to keep `no` silently if the diff widens
a public surface, and it does:

- Two literal members are added to
`ERROR_CODE_LEDGER['@objectstack/service-automation']`, a published `as
const` face.
- The ledger header (the objectstack-ai#16404 ruling) names this ledger, together with
`StandardErrorCode`, as the published contract face for error codes.
- The two precedents this card names, objectstack-ai#20206 and objectstack-ai#19441, each declared a
provenance-row addition as `@objectstack/spec` `minor` with `Clause-②:
yes`. Both say "What widens is the per-package face".

So the new note is `minor` with `Clause-②: yes`, and this body's second
line matches it. No accept set changes: the `ErrorCode` union is
unchanged. Every package is in the one `fixed` group, and
`@objectstack/spec` already has a pending `minor`, so the released
version is the same either way. The seat can flip it back if it reads
the precedent differently.

## Verification at `5748c8ddd` (base `99398542b`, merged with
`origin/main` `39ab2940e`)

- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (exit 0) derived 87 commands from the four
changed paths. Each ran on this head, with its exit code captured before
any pipe:
- **84 exit 0.** Among them: `check:error-code-provenance` ("scanned
2636 files; 335 registered-code stamp site(s): 316 listed, 19 waived" /
"OK"), `check-adr-0087-registration.mjs --base origin/main` ("this PR
adds no declared-breaking changeset (2 non-breaking changeset(s)
seen)"), `check-changeset-no-major.mjs --base origin/main` ("This diff
introduces no `major` bump"), `check:api-surface`,
`check:authorable-surface`, `check:docs`, `check:liveness`,
`check:error-code-casing`, `check:dispatcher-error-vocabulary`,
`check:cross-package-test-inputs`, `check:nul-bytes`,
`check:query-options-erasure` and `check:type-check-debt`.
- **1 exit 1, by design:** `check-empty-changeset.mjs --base
origin/main`, the DELIBERATE CORRECTION class above.
- **2 NOT MEASURED** (exit 3, `PREREQUISITE NOT MET`):
`check:dual-build-cjs-loads` (needs a full `pnpm build`) and
`check:lean-entry-closure` (needs `@objectstack/objectql` built). CI
builds the tree. Reason: a built tree was not produced here.
- Reconciled: `dispatch-gates.mjs --ran` exit 0, "87 derived famil(ies)
accounted for — 85 run, 2 NOT-MEASURED".
- `pnpm --filter @objectstack/spec build`: `VERDICT command-exit 0`.
Then `pnpm --filter @objectstack/spec check:generated`: "All 15
generated artifacts are up to date".
- `vitest run --project local` on `src/api` plus
`scripts/check-error-code-provenance.test.ts`: 47 files / 1546 tests
passed. The 12 other spec test files that read the ledger: 182 tests
passed.
- `pnpm --filter @objectstack/spec typecheck`: exit 0. That covers
`tsc`, `check:scripts-typecheck`, and `check:test-typecheck` ("52
file(s) / 246 error(s) / 135 pinned signature(s) held", unchanged).
- Main moved during the run: `origin/main` gained 13 commits after the
branch point. None touches the four files. The delta against `39ab2940e`
is exactly them (+70 / −4).

## Acceptance notes

- **Read and kept in the stage-① note:**
- "Runtime behaviour is deliberately **unchanged**: no connector sync
ever ran." This is about the retired connector-attached keys
(`syncConfig` / `fieldMappings`), and it stays true of them.
- "It carries no cadence (a `job` sets that)." This describes the
design; the corrected clause says nothing schedules a pull until the
`job` stage lands.
- **Reported to the seat, not changed here:**
- **The liveness lint throws on `connectorSource`.** The `live` +
`authorWarn` row reaches `describe()`'s sentinel throw, so `os validate`
/ `os build` stop with an internal lint error, and the runtime door
answers `authoring-rule-threw`, instead of warning on an authored
`connectorSource`. The comments in `authoring-rules.ts` and
`runtime-gate.inert-type-writes.test.ts` still say it warns. The fix is
a choice for its own card: teach `describe()` a caveat branch for
`live`, or change the row.
- **`turbo` 2.11.5 edits `AGENTS.md`.** It arrived with the
development-dependencies bump (`840ec9dab`, now on `main`). On every
turbo invocation it sees as an AI agent's, it appends a managed
`turborepo-agent-rules` block to `AGENTS.md`, a Tier H governed surface,
and `turbo.json` declares no `agentGuidance: false`. Measured here:
`pnpm exec turbo run build --filter='@objectstack/lint...'` and `pnpm
check:type-check-debt` each left `M AGENTS.md`, +11 lines. Each time it
was restored with `git checkout HEAD -- AGENTS.md` (blob `e9e211fc` =
HEAD) and never committed. An agent that commits with `-a` would carry
it into its PR.

---
_Generated by [Claude
Code](https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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/s tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants