fix(plugin-sharing): declared publicSharing.redactFields survive the object opting out - #14171

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-13856-redactfields-survive-optout
Sep 1, 2026
Merged

fix(plugin-sharing): declared publicSharing.redactFields survive the object opting out#14171
os-steve merged 3 commits into
mainfrom
claude/issue-13856-redactfields-survive-optout

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#13856

What

ShareLinkService.getPolicy() returned an EMPTY policy — redactFields: [] included — whenever the object's publicSharing block was absent or enabled !== true. resolveToken() computes the served redaction set as policy.redactFields ∪ the per-link redact_fields, so a link minted while the object was opted IN and redeemed after it was opted OUT kept resolving and started serving the very fields the object declares redacted: turning the feature OFF made the anonymous endpoint serve MORE data than it did while the feature was ON.

The fix: the disabled branch of getPolicy now reads the declared redactFields from the object's declared block regardless of enabled, so switching the feature off can never widen what an existing token serves. resolveToken is the only consumer of policy.redactFields (createLink never reads it), so the seam is exactly the served set.

Scope fence

Behavioural reproduction (the card was a source reading — "no test was run for it")

Measured pre-fix on this branch's base, which contains #13857's rework (loadRecordForServing / stillEligible present):

  • Mint on an object declaring redactFields: ['owner_id','cost'], per-link redactFields: ['body']; redeem while ON: served redaction set ["owner_id","cost","body"].
  • Flip publicSharing.enabled to false; redeem again:
    • claim (a) the link still resolves: true
    • claim (b) the redaction set collapsed: true["body"] (declared set gone; per-link only)

Both card claims hold post-#13857; premise valid.

Verification (at 85fb05e55 unless noted)

New tests (share-link-service.test.ts, [#13856] block):

  • THE REPRO — declared redactions still apply after opt-out, plus the directional assertion the card asks for: fields served with the switch OFF are a subset of fields served with it ON (asserted as "fields served ONLY after opting out — must be none", i.e. servedOff minus servedOn is empty).
  • boundary — off-path redaction set is exactly declared ∪ per-link ({'owner_id','cost','body'}).
  • control — enabled: true serves exactly declared ∪ per-link. Green in both directions ⇒ declared control, not ablation evidence.
  • reverse controls — an object with no publicSharing block sprouts NO redaction set out of this change; per-link-only redaction still applies there.
  • mint gate — enabled: false still refuses with code: SHARING_NOT_ENABLED, status: 422 (ADR-0112 envelope assertion, not bare toThrow).

Reverse verification of the tests (pre-fix run): exactly the 2 predicted reds — expected [ 'body' ] to include 'owner_id' and expected Set{ 'body' } to deeply equal Set{ 'owner_id', 'cost', 'body' } — 24/26 green.

Ablation from the committed fix. The subject resolves via the relative ./share-link-service.js src import under vitest transform — not via package exports/dist — so no build leg applies to either leg (stated, not skipped). Direction predicted first: exactly 2 reds (THE REPRO, boundary).

  • Mutation proven on disk: marker counts — removed-text raw?.redactFields 1 to 0, injected ABLATION-13856 0 to 1; worktree blob 32a9e6988… changed to 5d8f78cd9… (git hash-object, non-empty).
  • Observed: Tests 2 failed | 24 passed (26) — exactly the predicted two.
  • Restore proven by state: blob back to the HEAD blob 32a9e6988…, git diff HEAD empty, marker counts back to 1 / 0.

Package union at 85fb05e55 via the verify-lock entry point (its own printed verdict: VERDICT command-exit 0): vitest Test Files 30 passed (30) · Tests 705 passed (705) — includes the #13608 redemption-eligibility pins and the #7861/#8489/#9085 suites (boundary requirement: that gate untouched and green) — plus package typecheck green.

tsc program membership (tsc --noEmit --listFiles): src/share-link-service.ts in the program (1 hit). NOT MEASURED: the test file's types — the package tsconfig and tsconfig.scripts.json both exclude *.test.ts (0 hits in both programs); standing package convention, and both halves of the coverage gate are green (check:type-check-coverage OK; check:type-check-debt --re-measure: 29 entries, none above recorded).

Gate family re-derived from the actual diff (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, no path args; derivation stderr named this repo at 85fb05e55; both output sections read whole): 36 harvested families run, every exit captured before any pipe. All green except one:

  • check-test-completeness = NOT MEASURED by the gate's own documented local branch — it grades a CI-saved turbo run test log and instructs "running the family locally, record this gate as NOT MEASURED". CI supplies the log on every PR.
  • check:i18n and check:dual-build-cjs-loads first refused loudly (PREREQUISITE NOT MET, exit 3 ≠ red); green after their named closure builds — check-i18n-bundles: OK (9 package(s)), dual-build floors 90/58/520/1 vs this run 102/66/610/1.
  • check:nul-bytes green; control-character self-scan of all three edited files: no hits.

Fixture sweep: no existing fixture anywhere in the repo pins the disabled+redactFields combination (all publicSharing referents checked) — which is why the defect had no reading; the new tests are an addition, not a rewrite of a standing assertion.

Declared narrowing: pnpm lint (repo-wide ESLint) is CI-owned and was not run locally.

Clause-②

Clause-②: yes — this changes what the anonymous share-link endpoint actually serves (a deployment-visible narrowing on opted-out objects). Surface touched:

  • packages/plugins/plugin-sharing/src/share-link-service.ts (the getPolicy disabled branch only)
  • packages/plugins/plugin-sharing/src/share-link-service.test.ts
  • .changeset/share-link-redactfields-survive-optout.md (patch, declares the narrowing)

No packages/spec/** surface touched; no schema accept/reject change.

Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs

Generated by Claude Code


Generated by Claude Code

…ommit)
The dispatched dev agent was terminated mid-task by a session rate limit
(HTTP 429) before it could commit or push. This commit preserves what was on
disk so the container's reclamation cannot lose it.
NOT reviewed and NOT verified: no suite was run against this state, and the
work is incomplete by the agent's own account (it was still re-acquiring the
shared verify lock for its reproduction leg). Treat as a starting point to
amend, never as a finished change.
Deliberately excluded: repro-13856.scratch.test.ts, which the agent named as
scratch and plainly did not intend to ship.
…object opting out
getPolicy() collapsed to an empty policy whenever the object's publicSharing
block had enabled !== true — redactFields: [] included — so a link minted
while the object was opted in, redeemed after it was opted out, kept resolving
and started serving the very fields the object declares redacted. The declared
redaction set is now read from the declared block regardless of enabled, so
opting out can never widen what an existing token serves. The mint-time gate,
the redemption-time eligibility gate, the per-link union, and the no-block
path are unchanged; whether standing links should resolve at all after opt-out
is a separate pending ruling and is deliberately not implemented here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

github-actionsBot commented Sep 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 listnot 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 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 8 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 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from 802bca641b4d97366ffe5cc2baf1017c3fc264cb — the merge of head ab98ef31008845fb99874950211013ba5f0b5838 into base 45b9051248f86f362b042fa9de63295a8c224073, 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 802bca641b4d97366ffe5cc2baf1017c3fc264cb && git checkout 802bca641b4d97366ffe5cc2baf1017c3fc264cb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 45b9051248f86f362b042fa9de63295a8c224073 ab98ef31008845fb99874950211013ba5f0b5838 && git checkout -B drift-repro 45b9051248f86f362b042fa9de63295a8c224073 && git merge --no-ff ab98ef31008845fb99874950211013ba5f0b5838
node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

契约复审:PASS —— 逐字采纳,双载体标签已清

转录核验:

assistant 轮次: 48
盖章 "claude-fable-5": 48
非 fable 盖章(回退证据): 0

⇒ 每轮在档、零回退 ⇒ 逐字采纳。以下为复审席原文,⛔ 未改写、未删节、未润色:


契约复审:PASS

结论一行

退订后声明红act集单向收窄、无一路径放宽,#14033/#13608 栅栏完好,可照发。

逐题回答

Q1 — 每条路径都严格收窄:是。 证据链(全部读自 FETCH_HEAD = 85fb05e,即 PR head):

  • 全文件 old↔new diff 只有一个 hunk(share-link-service.ts:103,redactFields: []Array.isArray(raw?.redactFields) ? … : [])。三种输入形态逐一核过:无 publicSharing 块 → raw?.redactFields 为 undefined → [],与旧代码逐字节同值(:114);enabled !== true 且声明了数组 → 由 [] 变为声明集,是旧值的超集;enabled: true 分支(:117-125)逐字节未动。即 policy.redactFields(new) ⊇ policy.redactFields(old) 对一切输入成立
  • policy.redactFields 全仓只有一个消费点:share-link-service.ts:698new Set([...policy.redactFields, ...row.redact_fields ?? []]) —— 集合并,单调递增;逐-link redact_fields(含 null → ?? [])一侧不变。
  • 两个服务边沿的红act都是减法:share-link-routes.ts:119-126(applyRedactionincludes(k) continue 跳键复制)+ :278;runtime/src/domains/share-links.ts:125-133 + :176。红act集越大,served 键严格越少。
  • redactFields 不参与任何 serve/refuse 判定:resolveToken 的全部拒绝点(token 形状、行查找、revoked/expired、audience、password、存在性探针 :662-667、eligibility :694)均不读它;loadRecordForServing 的投影加宽只以 Boolean(eligibility) 为键(:665),而 disabled 分支上 eligibility 前后都是 undefined(:636)。
  • messages 子端点(share-link-routes.ts:313-342runtime/.../share-links.ts:193-212)前后都不消费 redactFields,该面集合不变。

结论:开关 OFF 时 served 集 ⊆ ON 时 served 集,且 new-served ⊆ old-served,零放宽。唯一多流出比特的通道是响应封皮里的 redactFields 键本身(字段,见非阻塞),不属记录字段。

Q2 — 范围栅栏是真的:是。 变更文件恰为 3 个(changeset、test、service,git diff --name-only 实测);service 全文件 diff 单 hunk,故 resolveToken 的 serve 判定(:600-716)、#13608 的门表达式 policy.enabled ? policy.eligibility : undefined(:483、:636)与 if (eligibility && !this.stillEligible(...)) return null(:694)、mint 侧 SHARING_NOT_ENABLED 422(:434-440)全部逐字节未动。警戒过的"悄悄改变块是否 in force":policy.enabled 仍是唯一 in-force 信号(:434/:443/:452/:483/:636),而"声明即算数、disabled 也算"在 record-share-cascade.ts:168,187 本就是先例且未被触碰。#14033 两个方向都没实现——测试里明写 resolve-通过是 given 而非 pin。

Q3 — disabled 时"声明块"的含义,连贯且有边界。 半生效是有意的且边界可陈述:(a) spec 对 redactFields 的既有文本(object.zod.ts:2251-2258)本就是"stripped from every response served via a share token, regardless of audience"——serve 时无条件;新代码符合 spec 文字,旧代码违背它。(b) 兄弟键里唯一在赎回时有力的是 eligibility,它仍被 enabled 门着(:636)——那正是 #14033 圈起的 serve/refuse 问题,changeset 已在"Unchanged, deliberately"里点名。(c) mint 时键(allowedAudiences/Permissions/maxExpiryDays)只在 permissive/isSystem 才可能于 disabled 下被触达,那条路整块统一不咨询——先例行为,未动。边界因此是:投影键在退订后存活;裁决键等 #14033。:103-113 的注释把方向说清了;下一个新键须按此归类,现有陈述足以承载。

Q4 — 发布形状零变化。ResolveShareLinkResult(spec/src/contracts/share-link-service.ts:111-115)未触碰,仍 { link, redactFields: string[] };getPolicy 两分支签名同为 redactFields: string[](:89-96)。另一个调用者 createLink 读的是 enabled/allowedPermissions/allowedAudiences/eligibility/maxExpiryDays(:434/:443/:452/:483/:512),从不读 policy.redactFields,故空策略契约对它无变化。附带:contracts:113 的注释"object default ∪ per-link"在旧代码的 disabled 路径上其实是假话,本修后才为真——形状不变,诚实度上升。

Q5 — 无需制造。 无阻塞级新发现。

tsc 边界(核实,非采信):属实。plugin-sharing/tsconfig.json include src/**/* 但 exclude **/*.test.ts;tsconfig.scripts.json 只 include scripts/**/*;package.json:18 的 typecheck 恰为这两个 program;vitest.config.tstypecheck 项,vitest 的 transform 只剥类型不查类型。含义要说白:新增 154 行测试不被任何 tsc program 读过,包级 typecheck 绿灯对它们的类型零作证;其唯一验证是 vitest 运行时执行。绿 typecheck 不得被当作测试类型覆盖引用。(dev 的 --listFiles 0/1 计数本身我未复跑——结构读法独立推出同一结论。)

非阻塞

  1. 退订对象的匿名赎回,响应封皮 redactFields 键现在会列出声明集的字段名(share-link-routes.ts:290runtime/.../share-links.ts:188)——旧行为只列逐-link 名。这是既有发布形状(contracts:113"Effective fields removed")的必然产物,且严格优于它取代的字段外泄;若要清账,一行即可:在 changeset 的声明清单里补记这一元数据面。不动代码。

我核了什么 / 我没核什么

核了:PR head 85fb05e 与 merge-base 的全文件 diff(service 单 hunk、全仓仅 3 文件);getPolicy 仅两个调用点、policy.redactFields 仅一个消费点;两个服务边沿的减法红act;两处 messages 端点不消费红act集;record-share-cascade 的"声明即算数"先例未动;spec 的 publicSharing 块全部六键及其文本;ResolveShareLinkResult 未动;测试 harness getSchema 按引用返回(share-link-service.test.ts:23),enabled 翻转确实能被服务观察到;tsconfig 双 program 的 include/exclude。

没核(NOT MEASURED):新测试的实际执行与 30 files / 705 tests 绿灯——由 CI 度量,dev 报绿;dev 的行为学复现([owner_id, cost, body] → [body])未复跑;tsc --listFiles 计数未复跑(结构证据等价)。以上任何一项都未被我当作绿色报告。


PM 侧收口

⭐ 这份裁决里最值钱的是 Q1 的结构性证明,不是测试

它没有停在「测试断言了子集方向」,而是从代码把单调性推了出来:单 hunk、三种输入形态逐一比对得 redactFields(new) ⊇ redactFields(old);全仓唯一消费点是一个集合(单调);两个服务边沿都是减法(红act集越大、served 键越少);且 redactFields不参与任何 serve/refuse 判定。⇒ 「零放宽」是被证明的,不是被抽样的。⛔ 一个只由测试支撑的方向性主张,永远只覆盖被想到的那些输入。

⚠️ 非阻塞项本席采纳,但记一句它为什么不阻塞

退订对象的响应封皮现在会列出声明集的字段名(旧行为只列逐-link 名)。这确实多流出了一点元数据。但:(a) 它是既有发布形状 redactFields: "Effective fields removed" 的必然产物,不是本 PR 新造的面;(b) 它换掉的是这些字段的本身外泄。⇒ 用字段名换字段值,是严格改善。复审席的建议(在 changeset 声明清单里补记这一元数据面)是账目问题,不改代码 —— 留给下一张动到该 changeset 的卡,或发布时一并记。

⚠️ tsc 边界照实记在账上

新增的 154 行测试不在任何 tsc program 里(包 tsconfig 排除 **/*.test.ts,vitest 只剥类型不查类型)。⇒ 包级 typecheck 绿灯对这些测试的类型零作证,它们唯一的验证是 vitest 的运行时执行。⛔ 这条不得被后来者当作"测试类型也过了"引用。


Generated by Claude Code

…shifted it
`check-system-context-census` failed on this branch: 10 problems over 145
anchors and 109 census sites — 5 `[anchor-is-not-a-read-site]` plus the 5
`[site-without-a-row]` reads they had rotted off.
Cause: this PR's single hunk in `getPolicy`'s disabled branch (the
`redactFields` ternary and its explaining comment, +12/-1 at line ~103)
pushed every `isSystem` read site below it down by exactly 11 lines, so
row 37's citations in `content/docs/permissions/system-context.mdx` no
longer resolved.
Pure line rot, not a population change — the read-site count is unchanged
at 109, each stale anchor pairs 1:1 with an orphaned site at a uniform
delta of +11, and the old and new lines are byte-identical:
:423 -> :434 :477 -> :488 :481 -> :492 :554 -> :565 :584 -> :595
Repaired with `node scripts/check-system-context-census.mjs --fix`, which
rewrote 5 anchors and REFUSED nothing. No anchor was hand-edited; no
source or test file is touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

check-system-context-census repaired — pure line rot, --fix refused nothing

Lint & Repo Gates was red on check-system-context-census at 85fb05e55: 10 problems over 145 anchors and 109 census sites — 5 [anchor-is-not-a-read-site] plus the 5 [site-without-a-row] reads they had rotted off.

Cause. This PR's single hunk in getPolicy's disabled branch (the redactFields ternary plus its explaining comment, +12/-1 at line ~103) pushed every isSystem read site below it down. Row 37 of content/docs/permissions/system-context.mdx still cited the old lines.

This was a pure shift, not a population change — verified before invoking --fix, not assumed from the earlier #13934 repair:

stale anchororphaned read sitedeltaold vs new line
:423:434+11byte-identical
:477:488+11byte-identical
:481:492+11byte-identical
:554:565+11byte-identical
:584:595+11byte-identical

Cardinality 5 to 5, uniform delta, and each old anchor line at HEAD~1 is byte-identical to its new site line at HEAD. Site count is unchanged at 109.

What --fix did.node scripts/check-system-context-census.mjs --fix, exit 0:

 re-anchored content/docs/permissions/system-context.mdx:138 `plugin-sharing/src/share-link-service.ts:423` -> `plugin-sharing/src/share-link-service.ts:434`
re-anchored content/docs/permissions/system-context.mdx:138 `:477` -> `:488`
re-anchored content/docs/permissions/system-context.mdx:138 `:481` -> `:492`
re-anchored content/docs/permissions/system-context.mdx:138 `:554` -> `:565`
re-anchored content/docs/permissions/system-context.mdx:138 `:584` -> `:595`
check-system-context-census --fix: 5 anchor(s) rewritten

It REFUSED nothing. A refusal prints NOT fixable: on stdout; that line count in the captured output is 0, against 5 re-anchored lines. This is a positive reading rather than merely absent text: the same run's --self-test exercises FIX: a population change is REFUSED, never guessed and four further #13490 refusal cases, all ok, so the refusal machinery was live and silent here.

Confirming re-runs, at the final commit ab98ef310:

  • node scripts/check-system-context-census.mjs — exit 0: OK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read.
  • node scripts/check-system-context-census.mjs --self-test — exit 0: all cases passed
  • git diff --statone file: content/docs/permissions/system-context.mdx | 2 +-. Only the five line numbers in row 37 changed; the row's prose is untouched. No anchor was hand-edited, and share-link-service.ts and its test are untouched.

Also green on this diff: check:doc-anchors, check:doc-frontmatter, check:doc-route-spelling, check:docs-section-name, check:section-landing-index, check:docs-single-h1, check:docs-redirects, check:docs-audit-scope, check:corpus-claim-drift, check:role-word, check:doc-authoring, check:keyed-text-bounds, check:nul-bytes.

Why no local run could have caught this

check-system-context-census is not derivable from this PR's diff. node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, run on the clean tree at 85fb05e55 before any edit, does not name it — it scores silent, the weakest verdict:

- node scripts/check-system-context-census.mjs [lint.yml] names: content/docs/permissions/system-context.mdx, packages/spec/src/kernel/execution-context.zod.ts, packages/spec/src/data/object.zod.ts, …

The gate's declared literals are the page plus a 29-entry NON_READ_ANCHORS roster; packages/plugins/plugin-sharing/src/share-link-service.ts appears nowhere in the gate source. Its real population is resolved at runtime — git ls-files packages examples in isystem-census.mjs. So a diff touching only packages/plugins/plugin-sharing/src/** can never pull this family in, while CI schedules it unconditionally. Reported for triage; no card filed from here.


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 10:43
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 67192ceSep 1, 2026
35 checks passed
@os-steve
os-steve deleted the claude/issue-13856-redactfields-survive-optout branch September 1, 2026 11:05
os-support-ai pushed a commit that referenced this pull request Sep 1, 2026
The auto-merge of origin/main kept this branch's pre-#14171 anchor rows
beside the row-21 edit, flunking the census 26 ways (paired stale
anchors / unanchored sites in engine.ts and share-link-service.ts —
pure adjacent-row line rot, zero population change). Resolution per the
tool, not by hand: took main's page wholesale, then re-derived with
check-system-context-census --fix, which rewrote exactly ONE anchor
(row 21, protocol.ts 1737 to 1741 — this branch's publisher-block
shift). Delta vs origin/main is that single line; bare gate green: 109
sites anchored, 145 anchors resolve.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sharing: turning publicSharing off leaves already-minted links serving — and silently drops the object's redactFields

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(plugin-sharing): declared publicSharing.redactFields survive the object opting out - #14171

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-13856-redactfields-survive-optout
Sep 1, 2026
Merged

fix(plugin-sharing): declared publicSharing.redactFields survive the object opting out#14171
os-steve merged 3 commits into
mainfrom
claude/issue-13856-redactfields-survive-optout

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#13856

What

ShareLinkService.getPolicy() returned an EMPTY policy — redactFields: [] included — whenever the object's publicSharing block was absent or enabled !== true. resolveToken() computes the served redaction set as policy.redactFields ∪ the per-link redact_fields, so a link minted while the object was opted IN and redeemed after it was opted OUT kept resolving and started serving the very fields the object declares redacted: turning the feature OFF made the anonymous endpoint serve MORE data than it did while the feature was ON.

The fix: the disabled branch of getPolicy now reads the declared redactFields from the object's declared block regardless of enabled, so switching the feature off can never widen what an existing token serves. resolveToken is the only consumer of policy.redactFields (createLink never reads it), so the seam is exactly the served set.

Scope fence

Behavioural reproduction (the card was a source reading — "no test was run for it")

Measured pre-fix on this branch's base, which contains #13857's rework (loadRecordForServing / stillEligible present):

  • Mint on an object declaring redactFields: ['owner_id','cost'], per-link redactFields: ['body']; redeem while ON: served redaction set ["owner_id","cost","body"].
  • Flip publicSharing.enabled to false; redeem again:
    • claim (a) the link still resolves: true
    • claim (b) the redaction set collapsed: true["body"] (declared set gone; per-link only)

Both card claims hold post-#13857; premise valid.

Verification (at 85fb05e55 unless noted)

New tests (share-link-service.test.ts, [#13856] block):

  • THE REPRO — declared redactions still apply after opt-out, plus the directional assertion the card asks for: fields served with the switch OFF are a subset of fields served with it ON (asserted as "fields served ONLY after opting out — must be none", i.e. servedOff minus servedOn is empty).
  • boundary — off-path redaction set is exactly declared ∪ per-link ({'owner_id','cost','body'}).
  • control — enabled: true serves exactly declared ∪ per-link. Green in both directions ⇒ declared control, not ablation evidence.
  • reverse controls — an object with no publicSharing block sprouts NO redaction set out of this change; per-link-only redaction still applies there.
  • mint gate — enabled: false still refuses with code: SHARING_NOT_ENABLED, status: 422 (ADR-0112 envelope assertion, not bare toThrow).

Reverse verification of the tests (pre-fix run): exactly the 2 predicted reds — expected [ 'body' ] to include 'owner_id' and expected Set{ 'body' } to deeply equal Set{ 'owner_id', 'cost', 'body' } — 24/26 green.

Ablation from the committed fix. The subject resolves via the relative ./share-link-service.js src import under vitest transform — not via package exports/dist — so no build leg applies to either leg (stated, not skipped). Direction predicted first: exactly 2 reds (THE REPRO, boundary).

  • Mutation proven on disk: marker counts — removed-text raw?.redactFields 1 to 0, injected ABLATION-13856 0 to 1; worktree blob 32a9e6988… changed to 5d8f78cd9… (git hash-object, non-empty).
  • Observed: Tests 2 failed | 24 passed (26) — exactly the predicted two.
  • Restore proven by state: blob back to the HEAD blob 32a9e6988…, git diff HEAD empty, marker counts back to 1 / 0.

Package union at 85fb05e55 via the verify-lock entry point (its own printed verdict: VERDICT command-exit 0): vitest Test Files 30 passed (30) · Tests 705 passed (705) — includes the #13608 redemption-eligibility pins and the #7861/#8489/#9085 suites (boundary requirement: that gate untouched and green) — plus package typecheck green.

tsc program membership (tsc --noEmit --listFiles): src/share-link-service.ts in the program (1 hit). NOT MEASURED: the test file's types — the package tsconfig and tsconfig.scripts.json both exclude *.test.ts (0 hits in both programs); standing package convention, and both halves of the coverage gate are green (check:type-check-coverage OK; check:type-check-debt --re-measure: 29 entries, none above recorded).

Gate family re-derived from the actual diff (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, no path args; derivation stderr named this repo at 85fb05e55; both output sections read whole): 36 harvested families run, every exit captured before any pipe. All green except one:

  • check-test-completeness = NOT MEASURED by the gate's own documented local branch — it grades a CI-saved turbo run test log and instructs "running the family locally, record this gate as NOT MEASURED". CI supplies the log on every PR.
  • check:i18n and check:dual-build-cjs-loads first refused loudly (PREREQUISITE NOT MET, exit 3 ≠ red); green after their named closure builds — check-i18n-bundles: OK (9 package(s)), dual-build floors 90/58/520/1 vs this run 102/66/610/1.
  • check:nul-bytes green; control-character self-scan of all three edited files: no hits.

Fixture sweep: no existing fixture anywhere in the repo pins the disabled+redactFields combination (all publicSharing referents checked) — which is why the defect had no reading; the new tests are an addition, not a rewrite of a standing assertion.

Declared narrowing: pnpm lint (repo-wide ESLint) is CI-owned and was not run locally.

Clause-②

Clause-②: yes — this changes what the anonymous share-link endpoint actually serves (a deployment-visible narrowing on opted-out objects). Surface touched:

  • packages/plugins/plugin-sharing/src/share-link-service.ts (the getPolicy disabled branch only)
  • packages/plugins/plugin-sharing/src/share-link-service.test.ts
  • .changeset/share-link-redactfields-survive-optout.md (patch, declares the narrowing)

No packages/spec/** surface touched; no schema accept/reject change.

Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs

Generated by Claude Code


Generated by Claude Code

…ommit)
The dispatched dev agent was terminated mid-task by a session rate limit
(HTTP 429) before it could commit or push. This commit preserves what was on
disk so the container's reclamation cannot lose it.
NOT reviewed and NOT verified: no suite was run against this state, and the
work is incomplete by the agent's own account (it was still re-acquiring the
shared verify lock for its reproduction leg). Treat as a starting point to
amend, never as a finished change.
Deliberately excluded: repro-13856.scratch.test.ts, which the agent named as
scratch and plainly did not intend to ship.
…object opting out
getPolicy() collapsed to an empty policy whenever the object's publicSharing
block had enabled !== true — redactFields: [] included — so a link minted
while the object was opted in, redeemed after it was opted out, kept resolving
and started serving the very fields the object declares redacted. The declared
redaction set is now read from the declared block regardless of enabled, so
opting out can never widen what an existing token serves. The mint-time gate,
the redemption-time eligibility gate, the per-link union, and the no-block
path are unchanged; whether standing links should resolve at all after opt-out
is a separate pending ruling and is deliberately not implemented here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

github-actionsBot commented Sep 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 listnot 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 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 8 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 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from 802bca641b4d97366ffe5cc2baf1017c3fc264cb — the merge of head ab98ef31008845fb99874950211013ba5f0b5838 into base 45b9051248f86f362b042fa9de63295a8c224073, 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 802bca641b4d97366ffe5cc2baf1017c3fc264cb && git checkout 802bca641b4d97366ffe5cc2baf1017c3fc264cb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 45b9051248f86f362b042fa9de63295a8c224073 ab98ef31008845fb99874950211013ba5f0b5838 && git checkout -B drift-repro 45b9051248f86f362b042fa9de63295a8c224073 && git merge --no-ff ab98ef31008845fb99874950211013ba5f0b5838
node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

契约复审:PASS —— 逐字采纳,双载体标签已清

转录核验:

assistant 轮次: 48
盖章 "claude-fable-5": 48
非 fable 盖章(回退证据): 0

⇒ 每轮在档、零回退 ⇒ 逐字采纳。以下为复审席原文,⛔ 未改写、未删节、未润色:


契约复审:PASS

结论一行

退订后声明红act集单向收窄、无一路径放宽,#14033/#13608 栅栏完好,可照发。

逐题回答

Q1 — 每条路径都严格收窄:是。 证据链(全部读自 FETCH_HEAD = 85fb05e,即 PR head):

  • 全文件 old↔new diff 只有一个 hunk(share-link-service.ts:103,redactFields: []Array.isArray(raw?.redactFields) ? … : [])。三种输入形态逐一核过:无 publicSharing 块 → raw?.redactFields 为 undefined → [],与旧代码逐字节同值(:114);enabled !== true 且声明了数组 → 由 [] 变为声明集,是旧值的超集;enabled: true 分支(:117-125)逐字节未动。即 policy.redactFields(new) ⊇ policy.redactFields(old) 对一切输入成立
  • policy.redactFields 全仓只有一个消费点:share-link-service.ts:698new Set([...policy.redactFields, ...row.redact_fields ?? []]) —— 集合并,单调递增;逐-link redact_fields(含 null → ?? [])一侧不变。
  • 两个服务边沿的红act都是减法:share-link-routes.ts:119-126(applyRedactionincludes(k) continue 跳键复制)+ :278;runtime/src/domains/share-links.ts:125-133 + :176。红act集越大,served 键严格越少。
  • redactFields 不参与任何 serve/refuse 判定:resolveToken 的全部拒绝点(token 形状、行查找、revoked/expired、audience、password、存在性探针 :662-667、eligibility :694)均不读它;loadRecordForServing 的投影加宽只以 Boolean(eligibility) 为键(:665),而 disabled 分支上 eligibility 前后都是 undefined(:636)。
  • messages 子端点(share-link-routes.ts:313-342runtime/.../share-links.ts:193-212)前后都不消费 redactFields,该面集合不变。

结论:开关 OFF 时 served 集 ⊆ ON 时 served 集,且 new-served ⊆ old-served,零放宽。唯一多流出比特的通道是响应封皮里的 redactFields 键本身(字段,见非阻塞),不属记录字段。

Q2 — 范围栅栏是真的:是。 变更文件恰为 3 个(changeset、test、service,git diff --name-only 实测);service 全文件 diff 单 hunk,故 resolveToken 的 serve 判定(:600-716)、#13608 的门表达式 policy.enabled ? policy.eligibility : undefined(:483、:636)与 if (eligibility && !this.stillEligible(...)) return null(:694)、mint 侧 SHARING_NOT_ENABLED 422(:434-440)全部逐字节未动。警戒过的"悄悄改变块是否 in force":policy.enabled 仍是唯一 in-force 信号(:434/:443/:452/:483/:636),而"声明即算数、disabled 也算"在 record-share-cascade.ts:168,187 本就是先例且未被触碰。#14033 两个方向都没实现——测试里明写 resolve-通过是 given 而非 pin。

Q3 — disabled 时"声明块"的含义,连贯且有边界。 半生效是有意的且边界可陈述:(a) spec 对 redactFields 的既有文本(object.zod.ts:2251-2258)本就是"stripped from every response served via a share token, regardless of audience"——serve 时无条件;新代码符合 spec 文字,旧代码违背它。(b) 兄弟键里唯一在赎回时有力的是 eligibility,它仍被 enabled 门着(:636)——那正是 #14033 圈起的 serve/refuse 问题,changeset 已在"Unchanged, deliberately"里点名。(c) mint 时键(allowedAudiences/Permissions/maxExpiryDays)只在 permissive/isSystem 才可能于 disabled 下被触达,那条路整块统一不咨询——先例行为,未动。边界因此是:投影键在退订后存活;裁决键等 #14033。:103-113 的注释把方向说清了;下一个新键须按此归类,现有陈述足以承载。

Q4 — 发布形状零变化。ResolveShareLinkResult(spec/src/contracts/share-link-service.ts:111-115)未触碰,仍 { link, redactFields: string[] };getPolicy 两分支签名同为 redactFields: string[](:89-96)。另一个调用者 createLink 读的是 enabled/allowedPermissions/allowedAudiences/eligibility/maxExpiryDays(:434/:443/:452/:483/:512),从不读 policy.redactFields,故空策略契约对它无变化。附带:contracts:113 的注释"object default ∪ per-link"在旧代码的 disabled 路径上其实是假话,本修后才为真——形状不变,诚实度上升。

Q5 — 无需制造。 无阻塞级新发现。

tsc 边界(核实,非采信):属实。plugin-sharing/tsconfig.json include src/**/* 但 exclude **/*.test.ts;tsconfig.scripts.json 只 include scripts/**/*;package.json:18 的 typecheck 恰为这两个 program;vitest.config.tstypecheck 项,vitest 的 transform 只剥类型不查类型。含义要说白:新增 154 行测试不被任何 tsc program 读过,包级 typecheck 绿灯对它们的类型零作证;其唯一验证是 vitest 运行时执行。绿 typecheck 不得被当作测试类型覆盖引用。(dev 的 --listFiles 0/1 计数本身我未复跑——结构读法独立推出同一结论。)

非阻塞

  1. 退订对象的匿名赎回,响应封皮 redactFields 键现在会列出声明集的字段名(share-link-routes.ts:290runtime/.../share-links.ts:188)——旧行为只列逐-link 名。这是既有发布形状(contracts:113"Effective fields removed")的必然产物,且严格优于它取代的字段外泄;若要清账,一行即可:在 changeset 的声明清单里补记这一元数据面。不动代码。

我核了什么 / 我没核什么

核了:PR head 85fb05e 与 merge-base 的全文件 diff(service 单 hunk、全仓仅 3 文件);getPolicy 仅两个调用点、policy.redactFields 仅一个消费点;两个服务边沿的减法红act;两处 messages 端点不消费红act集;record-share-cascade 的"声明即算数"先例未动;spec 的 publicSharing 块全部六键及其文本;ResolveShareLinkResult 未动;测试 harness getSchema 按引用返回(share-link-service.test.ts:23),enabled 翻转确实能被服务观察到;tsconfig 双 program 的 include/exclude。

没核(NOT MEASURED):新测试的实际执行与 30 files / 705 tests 绿灯——由 CI 度量,dev 报绿;dev 的行为学复现([owner_id, cost, body] → [body])未复跑;tsc --listFiles 计数未复跑(结构证据等价)。以上任何一项都未被我当作绿色报告。


PM 侧收口

⭐ 这份裁决里最值钱的是 Q1 的结构性证明,不是测试

它没有停在「测试断言了子集方向」,而是从代码把单调性推了出来:单 hunk、三种输入形态逐一比对得 redactFields(new) ⊇ redactFields(old);全仓唯一消费点是一个集合(单调);两个服务边沿都是减法(红act集越大、served 键越少);且 redactFields不参与任何 serve/refuse 判定。⇒ 「零放宽」是被证明的,不是被抽样的。⛔ 一个只由测试支撑的方向性主张,永远只覆盖被想到的那些输入。

⚠️ 非阻塞项本席采纳,但记一句它为什么不阻塞

退订对象的响应封皮现在会列出声明集的字段名(旧行为只列逐-link 名)。这确实多流出了一点元数据。但:(a) 它是既有发布形状 redactFields: "Effective fields removed" 的必然产物,不是本 PR 新造的面;(b) 它换掉的是这些字段的本身外泄。⇒ 用字段名换字段值,是严格改善。复审席的建议(在 changeset 声明清单里补记这一元数据面)是账目问题,不改代码 —— 留给下一张动到该 changeset 的卡,或发布时一并记。

⚠️ tsc 边界照实记在账上

新增的 154 行测试不在任何 tsc program 里(包 tsconfig 排除 **/*.test.ts,vitest 只剥类型不查类型)。⇒ 包级 typecheck 绿灯对这些测试的类型零作证,它们唯一的验证是 vitest 的运行时执行。⛔ 这条不得被后来者当作"测试类型也过了"引用。


Generated by Claude Code

…shifted it
`check-system-context-census` failed on this branch: 10 problems over 145
anchors and 109 census sites — 5 `[anchor-is-not-a-read-site]` plus the 5
`[site-without-a-row]` reads they had rotted off.
Cause: this PR's single hunk in `getPolicy`'s disabled branch (the
`redactFields` ternary and its explaining comment, +12/-1 at line ~103)
pushed every `isSystem` read site below it down by exactly 11 lines, so
row 37's citations in `content/docs/permissions/system-context.mdx` no
longer resolved.
Pure line rot, not a population change — the read-site count is unchanged
at 109, each stale anchor pairs 1:1 with an orphaned site at a uniform
delta of +11, and the old and new lines are byte-identical:
:423 -> :434 :477 -> :488 :481 -> :492 :554 -> :565 :584 -> :595
Repaired with `node scripts/check-system-context-census.mjs --fix`, which
rewrote 5 anchors and REFUSED nothing. No anchor was hand-edited; no
source or test file is touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

check-system-context-census repaired — pure line rot, --fix refused nothing

Lint & Repo Gates was red on check-system-context-census at 85fb05e55: 10 problems over 145 anchors and 109 census sites — 5 [anchor-is-not-a-read-site] plus the 5 [site-without-a-row] reads they had rotted off.

Cause. This PR's single hunk in getPolicy's disabled branch (the redactFields ternary plus its explaining comment, +12/-1 at line ~103) pushed every isSystem read site below it down. Row 37 of content/docs/permissions/system-context.mdx still cited the old lines.

This was a pure shift, not a population change — verified before invoking --fix, not assumed from the earlier #13934 repair:

stale anchororphaned read sitedeltaold vs new line
:423:434+11byte-identical
:477:488+11byte-identical
:481:492+11byte-identical
:554:565+11byte-identical
:584:595+11byte-identical

Cardinality 5 to 5, uniform delta, and each old anchor line at HEAD~1 is byte-identical to its new site line at HEAD. Site count is unchanged at 109.

What --fix did.node scripts/check-system-context-census.mjs --fix, exit 0:

 re-anchored content/docs/permissions/system-context.mdx:138 `plugin-sharing/src/share-link-service.ts:423` -> `plugin-sharing/src/share-link-service.ts:434`
re-anchored content/docs/permissions/system-context.mdx:138 `:477` -> `:488`
re-anchored content/docs/permissions/system-context.mdx:138 `:481` -> `:492`
re-anchored content/docs/permissions/system-context.mdx:138 `:554` -> `:565`
re-anchored content/docs/permissions/system-context.mdx:138 `:584` -> `:595`
check-system-context-census --fix: 5 anchor(s) rewritten

It REFUSED nothing. A refusal prints NOT fixable: on stdout; that line count in the captured output is 0, against 5 re-anchored lines. This is a positive reading rather than merely absent text: the same run's --self-test exercises FIX: a population change is REFUSED, never guessed and four further #13490 refusal cases, all ok, so the refusal machinery was live and silent here.

Confirming re-runs, at the final commit ab98ef310:

  • node scripts/check-system-context-census.mjs — exit 0: OK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read.
  • node scripts/check-system-context-census.mjs --self-test — exit 0: all cases passed
  • git diff --statone file: content/docs/permissions/system-context.mdx | 2 +-. Only the five line numbers in row 37 changed; the row's prose is untouched. No anchor was hand-edited, and share-link-service.ts and its test are untouched.

Also green on this diff: check:doc-anchors, check:doc-frontmatter, check:doc-route-spelling, check:docs-section-name, check:section-landing-index, check:docs-single-h1, check:docs-redirects, check:docs-audit-scope, check:corpus-claim-drift, check:role-word, check:doc-authoring, check:keyed-text-bounds, check:nul-bytes.

Why no local run could have caught this

check-system-context-census is not derivable from this PR's diff. node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, run on the clean tree at 85fb05e55 before any edit, does not name it — it scores silent, the weakest verdict:

- node scripts/check-system-context-census.mjs [lint.yml] names: content/docs/permissions/system-context.mdx, packages/spec/src/kernel/execution-context.zod.ts, packages/spec/src/data/object.zod.ts, …

The gate's declared literals are the page plus a 29-entry NON_READ_ANCHORS roster; packages/plugins/plugin-sharing/src/share-link-service.ts appears nowhere in the gate source. Its real population is resolved at runtime — git ls-files packages examples in isystem-census.mjs. So a diff touching only packages/plugins/plugin-sharing/src/** can never pull this family in, while CI schedules it unconditionally. Reported for triage; no card filed from here.


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 10:43
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 67192ceSep 1, 2026
35 checks passed
@os-steve
os-steve deleted the claude/issue-13856-redactfields-survive-optout branch September 1, 2026 11:05
os-support-ai pushed a commit that referenced this pull request Sep 1, 2026
The auto-merge of origin/main kept this branch's pre-#14171 anchor rows
beside the row-21 edit, flunking the census 26 ways (paired stale
anchors / unanchored sites in engine.ts and share-link-service.ts —
pure adjacent-row line rot, zero population change). Resolution per the
tool, not by hand: took main's page wholesale, then re-derived with
check-system-context-census --fix, which rewrote exactly ONE anchor
(row 21, protocol.ts 1737 to 1741 — this branch's publisher-block
shift). Delta vs origin/main is that single line; bare gate green: 109
sites anchored, 145 anchors resolve.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sharing: turning publicSharing off leaves already-minted links serving — and silently drops the object's redactFields

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-sharing): declared publicSharing.redactFields survive the object opting out - #14171

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-13856-redactfields-survive-optout
Sep 1, 2026
Merged

fix(plugin-sharing): declared publicSharing.redactFields survive the object opting out#14171
os-steve merged 3 commits into
mainfrom
claude/issue-13856-redactfields-survive-optout

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#13856

What

ShareLinkService.getPolicy() returned an EMPTY policy — redactFields: [] included — whenever the object's publicSharing block was absent or enabled !== true. resolveToken() computes the served redaction set as policy.redactFields ∪ the per-link redact_fields, so a link minted while the object was opted IN and redeemed after it was opted OUT kept resolving and started serving the very fields the object declares redacted: turning the feature OFF made the anonymous endpoint serve MORE data than it did while the feature was ON.

The fix: the disabled branch of getPolicy now reads the declared redactFields from the object's declared block regardless of enabled, so switching the feature off can never widen what an existing token serves. resolveToken is the only consumer of policy.redactFields (createLink never reads it), so the seam is exactly the served set.

Scope fence

Behavioural reproduction (the card was a source reading — "no test was run for it")

Measured pre-fix on this branch's base, which contains #13857's rework (loadRecordForServing / stillEligible present):

  • Mint on an object declaring redactFields: ['owner_id','cost'], per-link redactFields: ['body']; redeem while ON: served redaction set ["owner_id","cost","body"].
  • Flip publicSharing.enabled to false; redeem again:
    • claim (a) the link still resolves: true
    • claim (b) the redaction set collapsed: true["body"] (declared set gone; per-link only)

Both card claims hold post-#13857; premise valid.

Verification (at 85fb05e55 unless noted)

New tests (share-link-service.test.ts, [#13856] block):

  • THE REPRO — declared redactions still apply after opt-out, plus the directional assertion the card asks for: fields served with the switch OFF are a subset of fields served with it ON (asserted as "fields served ONLY after opting out — must be none", i.e. servedOff minus servedOn is empty).
  • boundary — off-path redaction set is exactly declared ∪ per-link ({'owner_id','cost','body'}).
  • control — enabled: true serves exactly declared ∪ per-link. Green in both directions ⇒ declared control, not ablation evidence.
  • reverse controls — an object with no publicSharing block sprouts NO redaction set out of this change; per-link-only redaction still applies there.
  • mint gate — enabled: false still refuses with code: SHARING_NOT_ENABLED, status: 422 (ADR-0112 envelope assertion, not bare toThrow).

Reverse verification of the tests (pre-fix run): exactly the 2 predicted reds — expected [ 'body' ] to include 'owner_id' and expected Set{ 'body' } to deeply equal Set{ 'owner_id', 'cost', 'body' } — 24/26 green.

Ablation from the committed fix. The subject resolves via the relative ./share-link-service.js src import under vitest transform — not via package exports/dist — so no build leg applies to either leg (stated, not skipped). Direction predicted first: exactly 2 reds (THE REPRO, boundary).

  • Mutation proven on disk: marker counts — removed-text raw?.redactFields 1 to 0, injected ABLATION-13856 0 to 1; worktree blob 32a9e6988… changed to 5d8f78cd9… (git hash-object, non-empty).
  • Observed: Tests 2 failed | 24 passed (26) — exactly the predicted two.
  • Restore proven by state: blob back to the HEAD blob 32a9e6988…, git diff HEAD empty, marker counts back to 1 / 0.

Package union at 85fb05e55 via the verify-lock entry point (its own printed verdict: VERDICT command-exit 0): vitest Test Files 30 passed (30) · Tests 705 passed (705) — includes the #13608 redemption-eligibility pins and the #7861/#8489/#9085 suites (boundary requirement: that gate untouched and green) — plus package typecheck green.

tsc program membership (tsc --noEmit --listFiles): src/share-link-service.ts in the program (1 hit). NOT MEASURED: the test file's types — the package tsconfig and tsconfig.scripts.json both exclude *.test.ts (0 hits in both programs); standing package convention, and both halves of the coverage gate are green (check:type-check-coverage OK; check:type-check-debt --re-measure: 29 entries, none above recorded).

Gate family re-derived from the actual diff (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, no path args; derivation stderr named this repo at 85fb05e55; both output sections read whole): 36 harvested families run, every exit captured before any pipe. All green except one:

  • check-test-completeness = NOT MEASURED by the gate's own documented local branch — it grades a CI-saved turbo run test log and instructs "running the family locally, record this gate as NOT MEASURED". CI supplies the log on every PR.
  • check:i18n and check:dual-build-cjs-loads first refused loudly (PREREQUISITE NOT MET, exit 3 ≠ red); green after their named closure builds — check-i18n-bundles: OK (9 package(s)), dual-build floors 90/58/520/1 vs this run 102/66/610/1.
  • check:nul-bytes green; control-character self-scan of all three edited files: no hits.

Fixture sweep: no existing fixture anywhere in the repo pins the disabled+redactFields combination (all publicSharing referents checked) — which is why the defect had no reading; the new tests are an addition, not a rewrite of a standing assertion.

Declared narrowing: pnpm lint (repo-wide ESLint) is CI-owned and was not run locally.

Clause-②

Clause-②: yes — this changes what the anonymous share-link endpoint actually serves (a deployment-visible narrowing on opted-out objects). Surface touched:

  • packages/plugins/plugin-sharing/src/share-link-service.ts (the getPolicy disabled branch only)
  • packages/plugins/plugin-sharing/src/share-link-service.test.ts
  • .changeset/share-link-redactfields-survive-optout.md (patch, declares the narrowing)

No packages/spec/** surface touched; no schema accept/reject change.

Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs

Generated by Claude Code


Generated by Claude Code

…ommit)
The dispatched dev agent was terminated mid-task by a session rate limit
(HTTP 429) before it could commit or push. This commit preserves what was on
disk so the container's reclamation cannot lose it.
NOT reviewed and NOT verified: no suite was run against this state, and the
work is incomplete by the agent's own account (it was still re-acquiring the
shared verify lock for its reproduction leg). Treat as a starting point to
amend, never as a finished change.
Deliberately excluded: repro-13856.scratch.test.ts, which the agent named as
scratch and plainly did not intend to ship.
…object opting out
getPolicy() collapsed to an empty policy whenever the object's publicSharing
block had enabled !== true — redactFields: [] included — so a link minted
while the object was opted in, redeemed after it was opted out, kept resolving
and started serving the very fields the object declares redacted. The declared
redaction set is now read from the declared block regardless of enabled, so
opting out can never widen what an existing token serves. The mint-time gate,
the redemption-time eligibility gate, the per-link union, and the no-block
path are unchanged; whether standing links should resolve at all after opt-out
is a separate pending ruling and is deliberately not implemented here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

github-actionsBot commented Sep 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 listnot 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 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 8 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 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from 802bca641b4d97366ffe5cc2baf1017c3fc264cb — the merge of head ab98ef31008845fb99874950211013ba5f0b5838 into base 45b9051248f86f362b042fa9de63295a8c224073, 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 802bca641b4d97366ffe5cc2baf1017c3fc264cb && git checkout 802bca641b4d97366ffe5cc2baf1017c3fc264cb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 45b9051248f86f362b042fa9de63295a8c224073 ab98ef31008845fb99874950211013ba5f0b5838 && git checkout -B drift-repro 45b9051248f86f362b042fa9de63295a8c224073 && git merge --no-ff ab98ef31008845fb99874950211013ba5f0b5838
node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

契约复审:PASS —— 逐字采纳,双载体标签已清

转录核验:

assistant 轮次: 48
盖章 "claude-fable-5": 48
非 fable 盖章(回退证据): 0

⇒ 每轮在档、零回退 ⇒ 逐字采纳。以下为复审席原文,⛔ 未改写、未删节、未润色:


契约复审:PASS

结论一行

退订后声明红act集单向收窄、无一路径放宽,#14033/#13608 栅栏完好,可照发。

逐题回答

Q1 — 每条路径都严格收窄:是。 证据链(全部读自 FETCH_HEAD = 85fb05e,即 PR head):

  • 全文件 old↔new diff 只有一个 hunk(share-link-service.ts:103,redactFields: []Array.isArray(raw?.redactFields) ? … : [])。三种输入形态逐一核过:无 publicSharing 块 → raw?.redactFields 为 undefined → [],与旧代码逐字节同值(:114);enabled !== true 且声明了数组 → 由 [] 变为声明集,是旧值的超集;enabled: true 分支(:117-125)逐字节未动。即 policy.redactFields(new) ⊇ policy.redactFields(old) 对一切输入成立
  • policy.redactFields 全仓只有一个消费点:share-link-service.ts:698new Set([...policy.redactFields, ...row.redact_fields ?? []]) —— 集合并,单调递增;逐-link redact_fields(含 null → ?? [])一侧不变。
  • 两个服务边沿的红act都是减法:share-link-routes.ts:119-126(applyRedactionincludes(k) continue 跳键复制)+ :278;runtime/src/domains/share-links.ts:125-133 + :176。红act集越大,served 键严格越少。
  • redactFields 不参与任何 serve/refuse 判定:resolveToken 的全部拒绝点(token 形状、行查找、revoked/expired、audience、password、存在性探针 :662-667、eligibility :694)均不读它;loadRecordForServing 的投影加宽只以 Boolean(eligibility) 为键(:665),而 disabled 分支上 eligibility 前后都是 undefined(:636)。
  • messages 子端点(share-link-routes.ts:313-342runtime/.../share-links.ts:193-212)前后都不消费 redactFields,该面集合不变。

结论:开关 OFF 时 served 集 ⊆ ON 时 served 集,且 new-served ⊆ old-served,零放宽。唯一多流出比特的通道是响应封皮里的 redactFields 键本身(字段,见非阻塞),不属记录字段。

Q2 — 范围栅栏是真的:是。 变更文件恰为 3 个(changeset、test、service,git diff --name-only 实测);service 全文件 diff 单 hunk,故 resolveToken 的 serve 判定(:600-716)、#13608 的门表达式 policy.enabled ? policy.eligibility : undefined(:483、:636)与 if (eligibility && !this.stillEligible(...)) return null(:694)、mint 侧 SHARING_NOT_ENABLED 422(:434-440)全部逐字节未动。警戒过的"悄悄改变块是否 in force":policy.enabled 仍是唯一 in-force 信号(:434/:443/:452/:483/:636),而"声明即算数、disabled 也算"在 record-share-cascade.ts:168,187 本就是先例且未被触碰。#14033 两个方向都没实现——测试里明写 resolve-通过是 given 而非 pin。

Q3 — disabled 时"声明块"的含义,连贯且有边界。 半生效是有意的且边界可陈述:(a) spec 对 redactFields 的既有文本(object.zod.ts:2251-2258)本就是"stripped from every response served via a share token, regardless of audience"——serve 时无条件;新代码符合 spec 文字,旧代码违背它。(b) 兄弟键里唯一在赎回时有力的是 eligibility,它仍被 enabled 门着(:636)——那正是 #14033 圈起的 serve/refuse 问题,changeset 已在"Unchanged, deliberately"里点名。(c) mint 时键(allowedAudiences/Permissions/maxExpiryDays)只在 permissive/isSystem 才可能于 disabled 下被触达,那条路整块统一不咨询——先例行为,未动。边界因此是:投影键在退订后存活;裁决键等 #14033。:103-113 的注释把方向说清了;下一个新键须按此归类,现有陈述足以承载。

Q4 — 发布形状零变化。ResolveShareLinkResult(spec/src/contracts/share-link-service.ts:111-115)未触碰,仍 { link, redactFields: string[] };getPolicy 两分支签名同为 redactFields: string[](:89-96)。另一个调用者 createLink 读的是 enabled/allowedPermissions/allowedAudiences/eligibility/maxExpiryDays(:434/:443/:452/:483/:512),从不读 policy.redactFields,故空策略契约对它无变化。附带:contracts:113 的注释"object default ∪ per-link"在旧代码的 disabled 路径上其实是假话,本修后才为真——形状不变,诚实度上升。

Q5 — 无需制造。 无阻塞级新发现。

tsc 边界(核实,非采信):属实。plugin-sharing/tsconfig.json include src/**/* 但 exclude **/*.test.ts;tsconfig.scripts.json 只 include scripts/**/*;package.json:18 的 typecheck 恰为这两个 program;vitest.config.tstypecheck 项,vitest 的 transform 只剥类型不查类型。含义要说白:新增 154 行测试不被任何 tsc program 读过,包级 typecheck 绿灯对它们的类型零作证;其唯一验证是 vitest 运行时执行。绿 typecheck 不得被当作测试类型覆盖引用。(dev 的 --listFiles 0/1 计数本身我未复跑——结构读法独立推出同一结论。)

非阻塞

  1. 退订对象的匿名赎回,响应封皮 redactFields 键现在会列出声明集的字段名(share-link-routes.ts:290runtime/.../share-links.ts:188)——旧行为只列逐-link 名。这是既有发布形状(contracts:113"Effective fields removed")的必然产物,且严格优于它取代的字段外泄;若要清账,一行即可:在 changeset 的声明清单里补记这一元数据面。不动代码。

我核了什么 / 我没核什么

核了:PR head 85fb05e 与 merge-base 的全文件 diff(service 单 hunk、全仓仅 3 文件);getPolicy 仅两个调用点、policy.redactFields 仅一个消费点;两个服务边沿的减法红act;两处 messages 端点不消费红act集;record-share-cascade 的"声明即算数"先例未动;spec 的 publicSharing 块全部六键及其文本;ResolveShareLinkResult 未动;测试 harness getSchema 按引用返回(share-link-service.test.ts:23),enabled 翻转确实能被服务观察到;tsconfig 双 program 的 include/exclude。

没核(NOT MEASURED):新测试的实际执行与 30 files / 705 tests 绿灯——由 CI 度量,dev 报绿;dev 的行为学复现([owner_id, cost, body] → [body])未复跑;tsc --listFiles 计数未复跑(结构证据等价)。以上任何一项都未被我当作绿色报告。


PM 侧收口

⭐ 这份裁决里最值钱的是 Q1 的结构性证明,不是测试

它没有停在「测试断言了子集方向」,而是从代码把单调性推了出来:单 hunk、三种输入形态逐一比对得 redactFields(new) ⊇ redactFields(old);全仓唯一消费点是一个集合(单调);两个服务边沿都是减法(红act集越大、served 键越少);且 redactFields不参与任何 serve/refuse 判定。⇒ 「零放宽」是被证明的,不是被抽样的。⛔ 一个只由测试支撑的方向性主张,永远只覆盖被想到的那些输入。

⚠️ 非阻塞项本席采纳,但记一句它为什么不阻塞

退订对象的响应封皮现在会列出声明集的字段名(旧行为只列逐-link 名)。这确实多流出了一点元数据。但:(a) 它是既有发布形状 redactFields: "Effective fields removed" 的必然产物,不是本 PR 新造的面;(b) 它换掉的是这些字段的本身外泄。⇒ 用字段名换字段值,是严格改善。复审席的建议(在 changeset 声明清单里补记这一元数据面)是账目问题,不改代码 —— 留给下一张动到该 changeset 的卡,或发布时一并记。

⚠️ tsc 边界照实记在账上

新增的 154 行测试不在任何 tsc program 里(包 tsconfig 排除 **/*.test.ts,vitest 只剥类型不查类型)。⇒ 包级 typecheck 绿灯对这些测试的类型零作证,它们唯一的验证是 vitest 的运行时执行。⛔ 这条不得被后来者当作"测试类型也过了"引用。


Generated by Claude Code

…shifted it
`check-system-context-census` failed on this branch: 10 problems over 145
anchors and 109 census sites — 5 `[anchor-is-not-a-read-site]` plus the 5
`[site-without-a-row]` reads they had rotted off.
Cause: this PR's single hunk in `getPolicy`'s disabled branch (the
`redactFields` ternary and its explaining comment, +12/-1 at line ~103)
pushed every `isSystem` read site below it down by exactly 11 lines, so
row 37's citations in `content/docs/permissions/system-context.mdx` no
longer resolved.
Pure line rot, not a population change — the read-site count is unchanged
at 109, each stale anchor pairs 1:1 with an orphaned site at a uniform
delta of +11, and the old and new lines are byte-identical:
:423 -> :434 :477 -> :488 :481 -> :492 :554 -> :565 :584 -> :595
Repaired with `node scripts/check-system-context-census.mjs --fix`, which
rewrote 5 anchors and REFUSED nothing. No anchor was hand-edited; no
source or test file is touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

check-system-context-census repaired — pure line rot, --fix refused nothing

Lint & Repo Gates was red on check-system-context-census at 85fb05e55: 10 problems over 145 anchors and 109 census sites — 5 [anchor-is-not-a-read-site] plus the 5 [site-without-a-row] reads they had rotted off.

Cause. This PR's single hunk in getPolicy's disabled branch (the redactFields ternary plus its explaining comment, +12/-1 at line ~103) pushed every isSystem read site below it down. Row 37 of content/docs/permissions/system-context.mdx still cited the old lines.

This was a pure shift, not a population change — verified before invoking --fix, not assumed from the earlier #13934 repair:

stale anchororphaned read sitedeltaold vs new line
:423:434+11byte-identical
:477:488+11byte-identical
:481:492+11byte-identical
:554:565+11byte-identical
:584:595+11byte-identical

Cardinality 5 to 5, uniform delta, and each old anchor line at HEAD~1 is byte-identical to its new site line at HEAD. Site count is unchanged at 109.

What --fix did.node scripts/check-system-context-census.mjs --fix, exit 0:

 re-anchored content/docs/permissions/system-context.mdx:138 `plugin-sharing/src/share-link-service.ts:423` -> `plugin-sharing/src/share-link-service.ts:434`
re-anchored content/docs/permissions/system-context.mdx:138 `:477` -> `:488`
re-anchored content/docs/permissions/system-context.mdx:138 `:481` -> `:492`
re-anchored content/docs/permissions/system-context.mdx:138 `:554` -> `:565`
re-anchored content/docs/permissions/system-context.mdx:138 `:584` -> `:595`
check-system-context-census --fix: 5 anchor(s) rewritten

It REFUSED nothing. A refusal prints NOT fixable: on stdout; that line count in the captured output is 0, against 5 re-anchored lines. This is a positive reading rather than merely absent text: the same run's --self-test exercises FIX: a population change is REFUSED, never guessed and four further #13490 refusal cases, all ok, so the refusal machinery was live and silent here.

Confirming re-runs, at the final commit ab98ef310:

  • node scripts/check-system-context-census.mjs — exit 0: OK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read.
  • node scripts/check-system-context-census.mjs --self-test — exit 0: all cases passed
  • git diff --statone file: content/docs/permissions/system-context.mdx | 2 +-. Only the five line numbers in row 37 changed; the row's prose is untouched. No anchor was hand-edited, and share-link-service.ts and its test are untouched.

Also green on this diff: check:doc-anchors, check:doc-frontmatter, check:doc-route-spelling, check:docs-section-name, check:section-landing-index, check:docs-single-h1, check:docs-redirects, check:docs-audit-scope, check:corpus-claim-drift, check:role-word, check:doc-authoring, check:keyed-text-bounds, check:nul-bytes.

Why no local run could have caught this

check-system-context-census is not derivable from this PR's diff. node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, run on the clean tree at 85fb05e55 before any edit, does not name it — it scores silent, the weakest verdict:

- node scripts/check-system-context-census.mjs [lint.yml] names: content/docs/permissions/system-context.mdx, packages/spec/src/kernel/execution-context.zod.ts, packages/spec/src/data/object.zod.ts, …

The gate's declared literals are the page plus a 29-entry NON_READ_ANCHORS roster; packages/plugins/plugin-sharing/src/share-link-service.ts appears nowhere in the gate source. Its real population is resolved at runtime — git ls-files packages examples in isystem-census.mjs. So a diff touching only packages/plugins/plugin-sharing/src/** can never pull this family in, while CI schedules it unconditionally. Reported for triage; no card filed from here.


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 10:43
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 67192ceSep 1, 2026
35 checks passed
@os-steve
os-steve deleted the claude/issue-13856-redactfields-survive-optout branch September 1, 2026 11:05
os-support-ai pushed a commit that referenced this pull request Sep 1, 2026
The auto-merge of origin/main kept this branch's pre-#14171 anchor rows
beside the row-21 edit, flunking the census 26 ways (paired stale
anchors / unanchored sites in engine.ts and share-link-service.ts —
pure adjacent-row line rot, zero population change). Resolution per the
tool, not by hand: took main's page wholesale, then re-derived with
check-system-context-census --fix, which rewrote exactly ONE anchor
(row 21, protocol.ts 1737 to 1741 — this branch's publisher-block
shift). Delta vs origin/main is that single line; bare gate green: 109
sites anchored, 145 anchors resolve.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sharing: turning publicSharing off leaves already-minted links serving — and silently drops the object's redactFields

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-sharing): declared publicSharing.redactFields survive the object opting out - #14171

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-13856-redactfields-survive-optout
Sep 1, 2026
Merged

fix(plugin-sharing): declared publicSharing.redactFields survive the object opting out#14171
os-steve merged 3 commits into
mainfrom
claude/issue-13856-redactfields-survive-optout

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#13856

What

ShareLinkService.getPolicy() returned an EMPTY policy — redactFields: [] included — whenever the object's publicSharing block was absent or enabled !== true. resolveToken() computes the served redaction set as policy.redactFields ∪ the per-link redact_fields, so a link minted while the object was opted IN and redeemed after it was opted OUT kept resolving and started serving the very fields the object declares redacted: turning the feature OFF made the anonymous endpoint serve MORE data than it did while the feature was ON.

The fix: the disabled branch of getPolicy now reads the declared redactFields from the object's declared block regardless of enabled, so switching the feature off can never widen what an existing token serves. resolveToken is the only consumer of policy.redactFields (createLink never reads it), so the seam is exactly the served set.

Scope fence

Behavioural reproduction (the card was a source reading — "no test was run for it")

Measured pre-fix on this branch's base, which contains #13857's rework (loadRecordForServing / stillEligible present):

  • Mint on an object declaring redactFields: ['owner_id','cost'], per-link redactFields: ['body']; redeem while ON: served redaction set ["owner_id","cost","body"].
  • Flip publicSharing.enabled to false; redeem again:
    • claim (a) the link still resolves: true
    • claim (b) the redaction set collapsed: true["body"] (declared set gone; per-link only)

Both card claims hold post-#13857; premise valid.

Verification (at 85fb05e55 unless noted)

New tests (share-link-service.test.ts, [#13856] block):

  • THE REPRO — declared redactions still apply after opt-out, plus the directional assertion the card asks for: fields served with the switch OFF are a subset of fields served with it ON (asserted as "fields served ONLY after opting out — must be none", i.e. servedOff minus servedOn is empty).
  • boundary — off-path redaction set is exactly declared ∪ per-link ({'owner_id','cost','body'}).
  • control — enabled: true serves exactly declared ∪ per-link. Green in both directions ⇒ declared control, not ablation evidence.
  • reverse controls — an object with no publicSharing block sprouts NO redaction set out of this change; per-link-only redaction still applies there.
  • mint gate — enabled: false still refuses with code: SHARING_NOT_ENABLED, status: 422 (ADR-0112 envelope assertion, not bare toThrow).

Reverse verification of the tests (pre-fix run): exactly the 2 predicted reds — expected [ 'body' ] to include 'owner_id' and expected Set{ 'body' } to deeply equal Set{ 'owner_id', 'cost', 'body' } — 24/26 green.

Ablation from the committed fix. The subject resolves via the relative ./share-link-service.js src import under vitest transform — not via package exports/dist — so no build leg applies to either leg (stated, not skipped). Direction predicted first: exactly 2 reds (THE REPRO, boundary).

  • Mutation proven on disk: marker counts — removed-text raw?.redactFields 1 to 0, injected ABLATION-13856 0 to 1; worktree blob 32a9e6988… changed to 5d8f78cd9… (git hash-object, non-empty).
  • Observed: Tests 2 failed | 24 passed (26) — exactly the predicted two.
  • Restore proven by state: blob back to the HEAD blob 32a9e6988…, git diff HEAD empty, marker counts back to 1 / 0.

Package union at 85fb05e55 via the verify-lock entry point (its own printed verdict: VERDICT command-exit 0): vitest Test Files 30 passed (30) · Tests 705 passed (705) — includes the #13608 redemption-eligibility pins and the #7861/#8489/#9085 suites (boundary requirement: that gate untouched and green) — plus package typecheck green.

tsc program membership (tsc --noEmit --listFiles): src/share-link-service.ts in the program (1 hit). NOT MEASURED: the test file's types — the package tsconfig and tsconfig.scripts.json both exclude *.test.ts (0 hits in both programs); standing package convention, and both halves of the coverage gate are green (check:type-check-coverage OK; check:type-check-debt --re-measure: 29 entries, none above recorded).

Gate family re-derived from the actual diff (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, no path args; derivation stderr named this repo at 85fb05e55; both output sections read whole): 36 harvested families run, every exit captured before any pipe. All green except one:

  • check-test-completeness = NOT MEASURED by the gate's own documented local branch — it grades a CI-saved turbo run test log and instructs "running the family locally, record this gate as NOT MEASURED". CI supplies the log on every PR.
  • check:i18n and check:dual-build-cjs-loads first refused loudly (PREREQUISITE NOT MET, exit 3 ≠ red); green after their named closure builds — check-i18n-bundles: OK (9 package(s)), dual-build floors 90/58/520/1 vs this run 102/66/610/1.
  • check:nul-bytes green; control-character self-scan of all three edited files: no hits.

Fixture sweep: no existing fixture anywhere in the repo pins the disabled+redactFields combination (all publicSharing referents checked) — which is why the defect had no reading; the new tests are an addition, not a rewrite of a standing assertion.

Declared narrowing: pnpm lint (repo-wide ESLint) is CI-owned and was not run locally.

Clause-②

Clause-②: yes — this changes what the anonymous share-link endpoint actually serves (a deployment-visible narrowing on opted-out objects). Surface touched:

  • packages/plugins/plugin-sharing/src/share-link-service.ts (the getPolicy disabled branch only)
  • packages/plugins/plugin-sharing/src/share-link-service.test.ts
  • .changeset/share-link-redactfields-survive-optout.md (patch, declares the narrowing)

No packages/spec/** surface touched; no schema accept/reject change.

Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs

Generated by Claude Code


Generated by Claude Code

…ommit)
The dispatched dev agent was terminated mid-task by a session rate limit
(HTTP 429) before it could commit or push. This commit preserves what was on
disk so the container's reclamation cannot lose it.
NOT reviewed and NOT verified: no suite was run against this state, and the
work is incomplete by the agent's own account (it was still re-acquiring the
shared verify lock for its reproduction leg). Treat as a starting point to
amend, never as a finished change.
Deliberately excluded: repro-13856.scratch.test.ts, which the agent named as
scratch and plainly did not intend to ship.
…object opting out
getPolicy() collapsed to an empty policy whenever the object's publicSharing
block had enabled !== true — redactFields: [] included — so a link minted
while the object was opted in, redeemed after it was opted out, kept resolving
and started serving the very fields the object declares redacted. The declared
redaction set is now read from the declared block regardless of enabled, so
opting out can never widen what an existing token serves. The mint-time gate,
the redemption-time eligibility gate, the per-link union, and the no-block
path are unchanged; whether standing links should resolve at all after opt-out
is a separate pending ruling and is deliberately not implemented here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

github-actionsBot commented Sep 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 listnot 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 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 8 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 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from 802bca641b4d97366ffe5cc2baf1017c3fc264cb — the merge of head ab98ef31008845fb99874950211013ba5f0b5838 into base 45b9051248f86f362b042fa9de63295a8c224073, 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 802bca641b4d97366ffe5cc2baf1017c3fc264cb && git checkout 802bca641b4d97366ffe5cc2baf1017c3fc264cb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 45b9051248f86f362b042fa9de63295a8c224073 ab98ef31008845fb99874950211013ba5f0b5838 && git checkout -B drift-repro 45b9051248f86f362b042fa9de63295a8c224073 && git merge --no-ff ab98ef31008845fb99874950211013ba5f0b5838
node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

契约复审:PASS —— 逐字采纳,双载体标签已清

转录核验:

assistant 轮次: 48
盖章 "claude-fable-5": 48
非 fable 盖章(回退证据): 0

⇒ 每轮在档、零回退 ⇒ 逐字采纳。以下为复审席原文,⛔ 未改写、未删节、未润色:


契约复审:PASS

结论一行

退订后声明红act集单向收窄、无一路径放宽,#14033/#13608 栅栏完好,可照发。

逐题回答

Q1 — 每条路径都严格收窄:是。 证据链(全部读自 FETCH_HEAD = 85fb05e,即 PR head):

  • 全文件 old↔new diff 只有一个 hunk(share-link-service.ts:103,redactFields: []Array.isArray(raw?.redactFields) ? … : [])。三种输入形态逐一核过:无 publicSharing 块 → raw?.redactFields 为 undefined → [],与旧代码逐字节同值(:114);enabled !== true 且声明了数组 → 由 [] 变为声明集,是旧值的超集;enabled: true 分支(:117-125)逐字节未动。即 policy.redactFields(new) ⊇ policy.redactFields(old) 对一切输入成立
  • policy.redactFields 全仓只有一个消费点:share-link-service.ts:698new Set([...policy.redactFields, ...row.redact_fields ?? []]) —— 集合并,单调递增;逐-link redact_fields(含 null → ?? [])一侧不变。
  • 两个服务边沿的红act都是减法:share-link-routes.ts:119-126(applyRedactionincludes(k) continue 跳键复制)+ :278;runtime/src/domains/share-links.ts:125-133 + :176。红act集越大,served 键严格越少。
  • redactFields 不参与任何 serve/refuse 判定:resolveToken 的全部拒绝点(token 形状、行查找、revoked/expired、audience、password、存在性探针 :662-667、eligibility :694)均不读它;loadRecordForServing 的投影加宽只以 Boolean(eligibility) 为键(:665),而 disabled 分支上 eligibility 前后都是 undefined(:636)。
  • messages 子端点(share-link-routes.ts:313-342runtime/.../share-links.ts:193-212)前后都不消费 redactFields,该面集合不变。

结论:开关 OFF 时 served 集 ⊆ ON 时 served 集,且 new-served ⊆ old-served,零放宽。唯一多流出比特的通道是响应封皮里的 redactFields 键本身(字段,见非阻塞),不属记录字段。

Q2 — 范围栅栏是真的:是。 变更文件恰为 3 个(changeset、test、service,git diff --name-only 实测);service 全文件 diff 单 hunk,故 resolveToken 的 serve 判定(:600-716)、#13608 的门表达式 policy.enabled ? policy.eligibility : undefined(:483、:636)与 if (eligibility && !this.stillEligible(...)) return null(:694)、mint 侧 SHARING_NOT_ENABLED 422(:434-440)全部逐字节未动。警戒过的"悄悄改变块是否 in force":policy.enabled 仍是唯一 in-force 信号(:434/:443/:452/:483/:636),而"声明即算数、disabled 也算"在 record-share-cascade.ts:168,187 本就是先例且未被触碰。#14033 两个方向都没实现——测试里明写 resolve-通过是 given 而非 pin。

Q3 — disabled 时"声明块"的含义,连贯且有边界。 半生效是有意的且边界可陈述:(a) spec 对 redactFields 的既有文本(object.zod.ts:2251-2258)本就是"stripped from every response served via a share token, regardless of audience"——serve 时无条件;新代码符合 spec 文字,旧代码违背它。(b) 兄弟键里唯一在赎回时有力的是 eligibility,它仍被 enabled 门着(:636)——那正是 #14033 圈起的 serve/refuse 问题,changeset 已在"Unchanged, deliberately"里点名。(c) mint 时键(allowedAudiences/Permissions/maxExpiryDays)只在 permissive/isSystem 才可能于 disabled 下被触达,那条路整块统一不咨询——先例行为,未动。边界因此是:投影键在退订后存活;裁决键等 #14033。:103-113 的注释把方向说清了;下一个新键须按此归类,现有陈述足以承载。

Q4 — 发布形状零变化。ResolveShareLinkResult(spec/src/contracts/share-link-service.ts:111-115)未触碰,仍 { link, redactFields: string[] };getPolicy 两分支签名同为 redactFields: string[](:89-96)。另一个调用者 createLink 读的是 enabled/allowedPermissions/allowedAudiences/eligibility/maxExpiryDays(:434/:443/:452/:483/:512),从不读 policy.redactFields,故空策略契约对它无变化。附带:contracts:113 的注释"object default ∪ per-link"在旧代码的 disabled 路径上其实是假话,本修后才为真——形状不变,诚实度上升。

Q5 — 无需制造。 无阻塞级新发现。

tsc 边界(核实,非采信):属实。plugin-sharing/tsconfig.json include src/**/* 但 exclude **/*.test.ts;tsconfig.scripts.json 只 include scripts/**/*;package.json:18 的 typecheck 恰为这两个 program;vitest.config.tstypecheck 项,vitest 的 transform 只剥类型不查类型。含义要说白:新增 154 行测试不被任何 tsc program 读过,包级 typecheck 绿灯对它们的类型零作证;其唯一验证是 vitest 运行时执行。绿 typecheck 不得被当作测试类型覆盖引用。(dev 的 --listFiles 0/1 计数本身我未复跑——结构读法独立推出同一结论。)

非阻塞

  1. 退订对象的匿名赎回,响应封皮 redactFields 键现在会列出声明集的字段名(share-link-routes.ts:290runtime/.../share-links.ts:188)——旧行为只列逐-link 名。这是既有发布形状(contracts:113"Effective fields removed")的必然产物,且严格优于它取代的字段外泄;若要清账,一行即可:在 changeset 的声明清单里补记这一元数据面。不动代码。

我核了什么 / 我没核什么

核了:PR head 85fb05e 与 merge-base 的全文件 diff(service 单 hunk、全仓仅 3 文件);getPolicy 仅两个调用点、policy.redactFields 仅一个消费点;两个服务边沿的减法红act;两处 messages 端点不消费红act集;record-share-cascade 的"声明即算数"先例未动;spec 的 publicSharing 块全部六键及其文本;ResolveShareLinkResult 未动;测试 harness getSchema 按引用返回(share-link-service.test.ts:23),enabled 翻转确实能被服务观察到;tsconfig 双 program 的 include/exclude。

没核(NOT MEASURED):新测试的实际执行与 30 files / 705 tests 绿灯——由 CI 度量,dev 报绿;dev 的行为学复现([owner_id, cost, body] → [body])未复跑;tsc --listFiles 计数未复跑(结构证据等价)。以上任何一项都未被我当作绿色报告。


PM 侧收口

⭐ 这份裁决里最值钱的是 Q1 的结构性证明,不是测试

它没有停在「测试断言了子集方向」,而是从代码把单调性推了出来:单 hunk、三种输入形态逐一比对得 redactFields(new) ⊇ redactFields(old);全仓唯一消费点是一个集合(单调);两个服务边沿都是减法(红act集越大、served 键越少);且 redactFields不参与任何 serve/refuse 判定。⇒ 「零放宽」是被证明的,不是被抽样的。⛔ 一个只由测试支撑的方向性主张,永远只覆盖被想到的那些输入。

⚠️ 非阻塞项本席采纳,但记一句它为什么不阻塞

退订对象的响应封皮现在会列出声明集的字段名(旧行为只列逐-link 名)。这确实多流出了一点元数据。但:(a) 它是既有发布形状 redactFields: "Effective fields removed" 的必然产物,不是本 PR 新造的面;(b) 它换掉的是这些字段的本身外泄。⇒ 用字段名换字段值,是严格改善。复审席的建议(在 changeset 声明清单里补记这一元数据面)是账目问题,不改代码 —— 留给下一张动到该 changeset 的卡,或发布时一并记。

⚠️ tsc 边界照实记在账上

新增的 154 行测试不在任何 tsc program 里(包 tsconfig 排除 **/*.test.ts,vitest 只剥类型不查类型)。⇒ 包级 typecheck 绿灯对这些测试的类型零作证,它们唯一的验证是 vitest 的运行时执行。⛔ 这条不得被后来者当作"测试类型也过了"引用。


Generated by Claude Code

…shifted it
`check-system-context-census` failed on this branch: 10 problems over 145
anchors and 109 census sites — 5 `[anchor-is-not-a-read-site]` plus the 5
`[site-without-a-row]` reads they had rotted off.
Cause: this PR's single hunk in `getPolicy`'s disabled branch (the
`redactFields` ternary and its explaining comment, +12/-1 at line ~103)
pushed every `isSystem` read site below it down by exactly 11 lines, so
row 37's citations in `content/docs/permissions/system-context.mdx` no
longer resolved.
Pure line rot, not a population change — the read-site count is unchanged
at 109, each stale anchor pairs 1:1 with an orphaned site at a uniform
delta of +11, and the old and new lines are byte-identical:
:423 -> :434 :477 -> :488 :481 -> :492 :554 -> :565 :584 -> :595
Repaired with `node scripts/check-system-context-census.mjs --fix`, which
rewrote 5 anchors and REFUSED nothing. No anchor was hand-edited; no
source or test file is touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

check-system-context-census repaired — pure line rot, --fix refused nothing

Lint & Repo Gates was red on check-system-context-census at 85fb05e55: 10 problems over 145 anchors and 109 census sites — 5 [anchor-is-not-a-read-site] plus the 5 [site-without-a-row] reads they had rotted off.

Cause. This PR's single hunk in getPolicy's disabled branch (the redactFields ternary plus its explaining comment, +12/-1 at line ~103) pushed every isSystem read site below it down. Row 37 of content/docs/permissions/system-context.mdx still cited the old lines.

This was a pure shift, not a population change — verified before invoking --fix, not assumed from the earlier #13934 repair:

stale anchororphaned read sitedeltaold vs new line
:423:434+11byte-identical
:477:488+11byte-identical
:481:492+11byte-identical
:554:565+11byte-identical
:584:595+11byte-identical

Cardinality 5 to 5, uniform delta, and each old anchor line at HEAD~1 is byte-identical to its new site line at HEAD. Site count is unchanged at 109.

What --fix did.node scripts/check-system-context-census.mjs --fix, exit 0:

 re-anchored content/docs/permissions/system-context.mdx:138 `plugin-sharing/src/share-link-service.ts:423` -> `plugin-sharing/src/share-link-service.ts:434`
re-anchored content/docs/permissions/system-context.mdx:138 `:477` -> `:488`
re-anchored content/docs/permissions/system-context.mdx:138 `:481` -> `:492`
re-anchored content/docs/permissions/system-context.mdx:138 `:554` -> `:565`
re-anchored content/docs/permissions/system-context.mdx:138 `:584` -> `:595`
check-system-context-census --fix: 5 anchor(s) rewritten

It REFUSED nothing. A refusal prints NOT fixable: on stdout; that line count in the captured output is 0, against 5 re-anchored lines. This is a positive reading rather than merely absent text: the same run's --self-test exercises FIX: a population change is REFUSED, never guessed and four further #13490 refusal cases, all ok, so the refusal machinery was live and silent here.

Confirming re-runs, at the final commit ab98ef310:

  • node scripts/check-system-context-census.mjs — exit 0: OK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read.
  • node scripts/check-system-context-census.mjs --self-test — exit 0: all cases passed
  • git diff --statone file: content/docs/permissions/system-context.mdx | 2 +-. Only the five line numbers in row 37 changed; the row's prose is untouched. No anchor was hand-edited, and share-link-service.ts and its test are untouched.

Also green on this diff: check:doc-anchors, check:doc-frontmatter, check:doc-route-spelling, check:docs-section-name, check:section-landing-index, check:docs-single-h1, check:docs-redirects, check:docs-audit-scope, check:corpus-claim-drift, check:role-word, check:doc-authoring, check:keyed-text-bounds, check:nul-bytes.

Why no local run could have caught this

check-system-context-census is not derivable from this PR's diff. node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, run on the clean tree at 85fb05e55 before any edit, does not name it — it scores silent, the weakest verdict:

- node scripts/check-system-context-census.mjs [lint.yml] names: content/docs/permissions/system-context.mdx, packages/spec/src/kernel/execution-context.zod.ts, packages/spec/src/data/object.zod.ts, …

The gate's declared literals are the page plus a 29-entry NON_READ_ANCHORS roster; packages/plugins/plugin-sharing/src/share-link-service.ts appears nowhere in the gate source. Its real population is resolved at runtime — git ls-files packages examples in isystem-census.mjs. So a diff touching only packages/plugins/plugin-sharing/src/** can never pull this family in, while CI schedules it unconditionally. Reported for triage; no card filed from here.


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 10:43
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 67192ceSep 1, 2026
35 checks passed
@os-steve
os-steve deleted the claude/issue-13856-redactfields-survive-optout branch September 1, 2026 11:05
os-support-ai pushed a commit that referenced this pull request Sep 1, 2026
The auto-merge of origin/main kept this branch's pre-#14171 anchor rows
beside the row-21 edit, flunking the census 26 ways (paired stale
anchors / unanchored sites in engine.ts and share-link-service.ts —
pure adjacent-row line rot, zero population change). Resolution per the
tool, not by hand: took main's page wholesale, then re-derived with
check-system-context-census --fix, which rewrote exactly ONE anchor
(row 21, protocol.ts 1737 to 1741 — this branch's publisher-block
shift). Delta vs origin/main is that single line; bare gate green: 109
sites anchored, 145 anchors resolve.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sharing: turning publicSharing off leaves already-minted links serving — and silently drops the object's redactFields

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(plugin-sharing): declared publicSharing.redactFields survive the object opting out - #14171

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-13856-redactfields-survive-optout
Sep 1, 2026
Merged

fix(plugin-sharing): declared publicSharing.redactFields survive the object opting out#14171
os-steve merged 3 commits into
mainfrom
claude/issue-13856-redactfields-survive-optout

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#13856

What

ShareLinkService.getPolicy() returned an EMPTY policy — redactFields: [] included — whenever the object's publicSharing block was absent or enabled !== true. resolveToken() computes the served redaction set as policy.redactFields ∪ the per-link redact_fields, so a link minted while the object was opted IN and redeemed after it was opted OUT kept resolving and started serving the very fields the object declares redacted: turning the feature OFF made the anonymous endpoint serve MORE data than it did while the feature was ON.

The fix: the disabled branch of getPolicy now reads the declared redactFields from the object's declared block regardless of enabled, so switching the feature off can never widen what an existing token serves. resolveToken is the only consumer of policy.redactFields (createLink never reads it), so the seam is exactly the served set.

Scope fence

Behavioural reproduction (the card was a source reading — "no test was run for it")

Measured pre-fix on this branch's base, which contains #13857's rework (loadRecordForServing / stillEligible present):

  • Mint on an object declaring redactFields: ['owner_id','cost'], per-link redactFields: ['body']; redeem while ON: served redaction set ["owner_id","cost","body"].
  • Flip publicSharing.enabled to false; redeem again:
    • claim (a) the link still resolves: true
    • claim (b) the redaction set collapsed: true["body"] (declared set gone; per-link only)

Both card claims hold post-#13857; premise valid.

Verification (at 85fb05e55 unless noted)

New tests (share-link-service.test.ts, [#13856] block):

  • THE REPRO — declared redactions still apply after opt-out, plus the directional assertion the card asks for: fields served with the switch OFF are a subset of fields served with it ON (asserted as "fields served ONLY after opting out — must be none", i.e. servedOff minus servedOn is empty).
  • boundary — off-path redaction set is exactly declared ∪ per-link ({'owner_id','cost','body'}).
  • control — enabled: true serves exactly declared ∪ per-link. Green in both directions ⇒ declared control, not ablation evidence.
  • reverse controls — an object with no publicSharing block sprouts NO redaction set out of this change; per-link-only redaction still applies there.
  • mint gate — enabled: false still refuses with code: SHARING_NOT_ENABLED, status: 422 (ADR-0112 envelope assertion, not bare toThrow).

Reverse verification of the tests (pre-fix run): exactly the 2 predicted reds — expected [ 'body' ] to include 'owner_id' and expected Set{ 'body' } to deeply equal Set{ 'owner_id', 'cost', 'body' } — 24/26 green.

Ablation from the committed fix. The subject resolves via the relative ./share-link-service.js src import under vitest transform — not via package exports/dist — so no build leg applies to either leg (stated, not skipped). Direction predicted first: exactly 2 reds (THE REPRO, boundary).

  • Mutation proven on disk: marker counts — removed-text raw?.redactFields 1 to 0, injected ABLATION-13856 0 to 1; worktree blob 32a9e6988… changed to 5d8f78cd9… (git hash-object, non-empty).
  • Observed: Tests 2 failed | 24 passed (26) — exactly the predicted two.
  • Restore proven by state: blob back to the HEAD blob 32a9e6988…, git diff HEAD empty, marker counts back to 1 / 0.

Package union at 85fb05e55 via the verify-lock entry point (its own printed verdict: VERDICT command-exit 0): vitest Test Files 30 passed (30) · Tests 705 passed (705) — includes the #13608 redemption-eligibility pins and the #7861/#8489/#9085 suites (boundary requirement: that gate untouched and green) — plus package typecheck green.

tsc program membership (tsc --noEmit --listFiles): src/share-link-service.ts in the program (1 hit). NOT MEASURED: the test file's types — the package tsconfig and tsconfig.scripts.json both exclude *.test.ts (0 hits in both programs); standing package convention, and both halves of the coverage gate are green (check:type-check-coverage OK; check:type-check-debt --re-measure: 29 entries, none above recorded).

Gate family re-derived from the actual diff (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, no path args; derivation stderr named this repo at 85fb05e55; both output sections read whole): 36 harvested families run, every exit captured before any pipe. All green except one:

  • check-test-completeness = NOT MEASURED by the gate's own documented local branch — it grades a CI-saved turbo run test log and instructs "running the family locally, record this gate as NOT MEASURED". CI supplies the log on every PR.
  • check:i18n and check:dual-build-cjs-loads first refused loudly (PREREQUISITE NOT MET, exit 3 ≠ red); green after their named closure builds — check-i18n-bundles: OK (9 package(s)), dual-build floors 90/58/520/1 vs this run 102/66/610/1.
  • check:nul-bytes green; control-character self-scan of all three edited files: no hits.

Fixture sweep: no existing fixture anywhere in the repo pins the disabled+redactFields combination (all publicSharing referents checked) — which is why the defect had no reading; the new tests are an addition, not a rewrite of a standing assertion.

Declared narrowing: pnpm lint (repo-wide ESLint) is CI-owned and was not run locally.

Clause-②

Clause-②: yes — this changes what the anonymous share-link endpoint actually serves (a deployment-visible narrowing on opted-out objects). Surface touched:

  • packages/plugins/plugin-sharing/src/share-link-service.ts (the getPolicy disabled branch only)
  • packages/plugins/plugin-sharing/src/share-link-service.test.ts
  • .changeset/share-link-redactfields-survive-optout.md (patch, declares the narrowing)

No packages/spec/** surface touched; no schema accept/reject change.

Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs

Generated by Claude Code


Generated by Claude Code

…ommit)
The dispatched dev agent was terminated mid-task by a session rate limit
(HTTP 429) before it could commit or push. This commit preserves what was on
disk so the container's reclamation cannot lose it.
NOT reviewed and NOT verified: no suite was run against this state, and the
work is incomplete by the agent's own account (it was still re-acquiring the
shared verify lock for its reproduction leg). Treat as a starting point to
amend, never as a finished change.
Deliberately excluded: repro-13856.scratch.test.ts, which the agent named as
scratch and plainly did not intend to ship.
…object opting out
getPolicy() collapsed to an empty policy whenever the object's publicSharing
block had enabled !== true — redactFields: [] included — so a link minted
while the object was opted in, redeemed after it was opted out, kept resolving
and started serving the very fields the object declares redacted. The declared
redaction set is now read from the declared block regardless of enabled, so
opting out can never widen what an existing token serves. The mint-time gate,
the redemption-time eligibility gate, the per-link union, and the no-block
path are unchanged; whether standing links should resolve at all after opt-out
is a separate pending ruling and is deliberately not implemented here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

github-actionsBot commented Sep 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 listnot 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 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 8 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 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from 802bca641b4d97366ffe5cc2baf1017c3fc264cb — the merge of head ab98ef31008845fb99874950211013ba5f0b5838 into base 45b9051248f86f362b042fa9de63295a8c224073, 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 802bca641b4d97366ffe5cc2baf1017c3fc264cb && git checkout 802bca641b4d97366ffe5cc2baf1017c3fc264cb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 45b9051248f86f362b042fa9de63295a8c224073 ab98ef31008845fb99874950211013ba5f0b5838 && git checkout -B drift-repro 45b9051248f86f362b042fa9de63295a8c224073 && git merge --no-ff ab98ef31008845fb99874950211013ba5f0b5838
node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

契约复审:PASS —— 逐字采纳,双载体标签已清

转录核验:

assistant 轮次: 48
盖章 "claude-fable-5": 48
非 fable 盖章(回退证据): 0

⇒ 每轮在档、零回退 ⇒ 逐字采纳。以下为复审席原文,⛔ 未改写、未删节、未润色:


契约复审:PASS

结论一行

退订后声明红act集单向收窄、无一路径放宽,#14033/#13608 栅栏完好,可照发。

逐题回答

Q1 — 每条路径都严格收窄:是。 证据链(全部读自 FETCH_HEAD = 85fb05e,即 PR head):

  • 全文件 old↔new diff 只有一个 hunk(share-link-service.ts:103,redactFields: []Array.isArray(raw?.redactFields) ? … : [])。三种输入形态逐一核过:无 publicSharing 块 → raw?.redactFields 为 undefined → [],与旧代码逐字节同值(:114);enabled !== true 且声明了数组 → 由 [] 变为声明集,是旧值的超集;enabled: true 分支(:117-125)逐字节未动。即 policy.redactFields(new) ⊇ policy.redactFields(old) 对一切输入成立
  • policy.redactFields 全仓只有一个消费点:share-link-service.ts:698new Set([...policy.redactFields, ...row.redact_fields ?? []]) —— 集合并,单调递增;逐-link redact_fields(含 null → ?? [])一侧不变。
  • 两个服务边沿的红act都是减法:share-link-routes.ts:119-126(applyRedactionincludes(k) continue 跳键复制)+ :278;runtime/src/domains/share-links.ts:125-133 + :176。红act集越大,served 键严格越少。
  • redactFields 不参与任何 serve/refuse 判定:resolveToken 的全部拒绝点(token 形状、行查找、revoked/expired、audience、password、存在性探针 :662-667、eligibility :694)均不读它;loadRecordForServing 的投影加宽只以 Boolean(eligibility) 为键(:665),而 disabled 分支上 eligibility 前后都是 undefined(:636)。
  • messages 子端点(share-link-routes.ts:313-342runtime/.../share-links.ts:193-212)前后都不消费 redactFields,该面集合不变。

结论:开关 OFF 时 served 集 ⊆ ON 时 served 集,且 new-served ⊆ old-served,零放宽。唯一多流出比特的通道是响应封皮里的 redactFields 键本身(字段,见非阻塞),不属记录字段。

Q2 — 范围栅栏是真的:是。 变更文件恰为 3 个(changeset、test、service,git diff --name-only 实测);service 全文件 diff 单 hunk,故 resolveToken 的 serve 判定(:600-716)、#13608 的门表达式 policy.enabled ? policy.eligibility : undefined(:483、:636)与 if (eligibility && !this.stillEligible(...)) return null(:694)、mint 侧 SHARING_NOT_ENABLED 422(:434-440)全部逐字节未动。警戒过的"悄悄改变块是否 in force":policy.enabled 仍是唯一 in-force 信号(:434/:443/:452/:483/:636),而"声明即算数、disabled 也算"在 record-share-cascade.ts:168,187 本就是先例且未被触碰。#14033 两个方向都没实现——测试里明写 resolve-通过是 given 而非 pin。

Q3 — disabled 时"声明块"的含义,连贯且有边界。 半生效是有意的且边界可陈述:(a) spec 对 redactFields 的既有文本(object.zod.ts:2251-2258)本就是"stripped from every response served via a share token, regardless of audience"——serve 时无条件;新代码符合 spec 文字,旧代码违背它。(b) 兄弟键里唯一在赎回时有力的是 eligibility,它仍被 enabled 门着(:636)——那正是 #14033 圈起的 serve/refuse 问题,changeset 已在"Unchanged, deliberately"里点名。(c) mint 时键(allowedAudiences/Permissions/maxExpiryDays)只在 permissive/isSystem 才可能于 disabled 下被触达,那条路整块统一不咨询——先例行为,未动。边界因此是:投影键在退订后存活;裁决键等 #14033。:103-113 的注释把方向说清了;下一个新键须按此归类,现有陈述足以承载。

Q4 — 发布形状零变化。ResolveShareLinkResult(spec/src/contracts/share-link-service.ts:111-115)未触碰,仍 { link, redactFields: string[] };getPolicy 两分支签名同为 redactFields: string[](:89-96)。另一个调用者 createLink 读的是 enabled/allowedPermissions/allowedAudiences/eligibility/maxExpiryDays(:434/:443/:452/:483/:512),从不读 policy.redactFields,故空策略契约对它无变化。附带:contracts:113 的注释"object default ∪ per-link"在旧代码的 disabled 路径上其实是假话,本修后才为真——形状不变,诚实度上升。

Q5 — 无需制造。 无阻塞级新发现。

tsc 边界(核实,非采信):属实。plugin-sharing/tsconfig.json include src/**/* 但 exclude **/*.test.ts;tsconfig.scripts.json 只 include scripts/**/*;package.json:18 的 typecheck 恰为这两个 program;vitest.config.tstypecheck 项,vitest 的 transform 只剥类型不查类型。含义要说白:新增 154 行测试不被任何 tsc program 读过,包级 typecheck 绿灯对它们的类型零作证;其唯一验证是 vitest 运行时执行。绿 typecheck 不得被当作测试类型覆盖引用。(dev 的 --listFiles 0/1 计数本身我未复跑——结构读法独立推出同一结论。)

非阻塞

  1. 退订对象的匿名赎回,响应封皮 redactFields 键现在会列出声明集的字段名(share-link-routes.ts:290runtime/.../share-links.ts:188)——旧行为只列逐-link 名。这是既有发布形状(contracts:113"Effective fields removed")的必然产物,且严格优于它取代的字段外泄;若要清账,一行即可:在 changeset 的声明清单里补记这一元数据面。不动代码。

我核了什么 / 我没核什么

核了:PR head 85fb05e 与 merge-base 的全文件 diff(service 单 hunk、全仓仅 3 文件);getPolicy 仅两个调用点、policy.redactFields 仅一个消费点;两个服务边沿的减法红act;两处 messages 端点不消费红act集;record-share-cascade 的"声明即算数"先例未动;spec 的 publicSharing 块全部六键及其文本;ResolveShareLinkResult 未动;测试 harness getSchema 按引用返回(share-link-service.test.ts:23),enabled 翻转确实能被服务观察到;tsconfig 双 program 的 include/exclude。

没核(NOT MEASURED):新测试的实际执行与 30 files / 705 tests 绿灯——由 CI 度量,dev 报绿;dev 的行为学复现([owner_id, cost, body] → [body])未复跑;tsc --listFiles 计数未复跑(结构证据等价)。以上任何一项都未被我当作绿色报告。


PM 侧收口

⭐ 这份裁决里最值钱的是 Q1 的结构性证明,不是测试

它没有停在「测试断言了子集方向」,而是从代码把单调性推了出来:单 hunk、三种输入形态逐一比对得 redactFields(new) ⊇ redactFields(old);全仓唯一消费点是一个集合(单调);两个服务边沿都是减法(红act集越大、served 键越少);且 redactFields不参与任何 serve/refuse 判定。⇒ 「零放宽」是被证明的,不是被抽样的。⛔ 一个只由测试支撑的方向性主张,永远只覆盖被想到的那些输入。

⚠️ 非阻塞项本席采纳,但记一句它为什么不阻塞

退订对象的响应封皮现在会列出声明集的字段名(旧行为只列逐-link 名)。这确实多流出了一点元数据。但:(a) 它是既有发布形状 redactFields: "Effective fields removed" 的必然产物,不是本 PR 新造的面;(b) 它换掉的是这些字段的本身外泄。⇒ 用字段名换字段值,是严格改善。复审席的建议(在 changeset 声明清单里补记这一元数据面)是账目问题,不改代码 —— 留给下一张动到该 changeset 的卡,或发布时一并记。

⚠️ tsc 边界照实记在账上

新增的 154 行测试不在任何 tsc program 里(包 tsconfig 排除 **/*.test.ts,vitest 只剥类型不查类型)。⇒ 包级 typecheck 绿灯对这些测试的类型零作证,它们唯一的验证是 vitest 的运行时执行。⛔ 这条不得被后来者当作"测试类型也过了"引用。


Generated by Claude Code

…shifted it
`check-system-context-census` failed on this branch: 10 problems over 145
anchors and 109 census sites — 5 `[anchor-is-not-a-read-site]` plus the 5
`[site-without-a-row]` reads they had rotted off.
Cause: this PR's single hunk in `getPolicy`'s disabled branch (the
`redactFields` ternary and its explaining comment, +12/-1 at line ~103)
pushed every `isSystem` read site below it down by exactly 11 lines, so
row 37's citations in `content/docs/permissions/system-context.mdx` no
longer resolved.
Pure line rot, not a population change — the read-site count is unchanged
at 109, each stale anchor pairs 1:1 with an orphaned site at a uniform
delta of +11, and the old and new lines are byte-identical:
:423 -> :434 :477 -> :488 :481 -> :492 :554 -> :565 :584 -> :595
Repaired with `node scripts/check-system-context-census.mjs --fix`, which
rewrote 5 anchors and REFUSED nothing. No anchor was hand-edited; no
source or test file is touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

check-system-context-census repaired — pure line rot, --fix refused nothing

Lint & Repo Gates was red on check-system-context-census at 85fb05e55: 10 problems over 145 anchors and 109 census sites — 5 [anchor-is-not-a-read-site] plus the 5 [site-without-a-row] reads they had rotted off.

Cause. This PR's single hunk in getPolicy's disabled branch (the redactFields ternary plus its explaining comment, +12/-1 at line ~103) pushed every isSystem read site below it down. Row 37 of content/docs/permissions/system-context.mdx still cited the old lines.

This was a pure shift, not a population change — verified before invoking --fix, not assumed from the earlier #13934 repair:

stale anchororphaned read sitedeltaold vs new line
:423:434+11byte-identical
:477:488+11byte-identical
:481:492+11byte-identical
:554:565+11byte-identical
:584:595+11byte-identical

Cardinality 5 to 5, uniform delta, and each old anchor line at HEAD~1 is byte-identical to its new site line at HEAD. Site count is unchanged at 109.

What --fix did.node scripts/check-system-context-census.mjs --fix, exit 0:

 re-anchored content/docs/permissions/system-context.mdx:138 `plugin-sharing/src/share-link-service.ts:423` -> `plugin-sharing/src/share-link-service.ts:434`
re-anchored content/docs/permissions/system-context.mdx:138 `:477` -> `:488`
re-anchored content/docs/permissions/system-context.mdx:138 `:481` -> `:492`
re-anchored content/docs/permissions/system-context.mdx:138 `:554` -> `:565`
re-anchored content/docs/permissions/system-context.mdx:138 `:584` -> `:595`
check-system-context-census --fix: 5 anchor(s) rewritten

It REFUSED nothing. A refusal prints NOT fixable: on stdout; that line count in the captured output is 0, against 5 re-anchored lines. This is a positive reading rather than merely absent text: the same run's --self-test exercises FIX: a population change is REFUSED, never guessed and four further #13490 refusal cases, all ok, so the refusal machinery was live and silent here.

Confirming re-runs, at the final commit ab98ef310:

  • node scripts/check-system-context-census.mjs — exit 0: OK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read.
  • node scripts/check-system-context-census.mjs --self-test — exit 0: all cases passed
  • git diff --statone file: content/docs/permissions/system-context.mdx | 2 +-. Only the five line numbers in row 37 changed; the row's prose is untouched. No anchor was hand-edited, and share-link-service.ts and its test are untouched.

Also green on this diff: check:doc-anchors, check:doc-frontmatter, check:doc-route-spelling, check:docs-section-name, check:section-landing-index, check:docs-single-h1, check:docs-redirects, check:docs-audit-scope, check:corpus-claim-drift, check:role-word, check:doc-authoring, check:keyed-text-bounds, check:nul-bytes.

Why no local run could have caught this

check-system-context-census is not derivable from this PR's diff. node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, run on the clean tree at 85fb05e55 before any edit, does not name it — it scores silent, the weakest verdict:

- node scripts/check-system-context-census.mjs [lint.yml] names: content/docs/permissions/system-context.mdx, packages/spec/src/kernel/execution-context.zod.ts, packages/spec/src/data/object.zod.ts, …

The gate's declared literals are the page plus a 29-entry NON_READ_ANCHORS roster; packages/plugins/plugin-sharing/src/share-link-service.ts appears nowhere in the gate source. Its real population is resolved at runtime — git ls-files packages examples in isystem-census.mjs. So a diff touching only packages/plugins/plugin-sharing/src/** can never pull this family in, while CI schedules it unconditionally. Reported for triage; no card filed from here.


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 10:43
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 67192ceSep 1, 2026
35 checks passed
@os-steve
os-steve deleted the claude/issue-13856-redactfields-survive-optout branch September 1, 2026 11:05
os-support-ai pushed a commit that referenced this pull request Sep 1, 2026
The auto-merge of origin/main kept this branch's pre-#14171 anchor rows
beside the row-21 edit, flunking the census 26 ways (paired stale
anchors / unanchored sites in engine.ts and share-link-service.ts —
pure adjacent-row line rot, zero population change). Resolution per the
tool, not by hand: took main's page wholesale, then re-derived with
check-system-context-census --fix, which rewrote exactly ONE anchor
(row 21, protocol.ts 1737 to 1741 — this branch's publisher-block
shift). Delta vs origin/main is that single line; bare gate green: 109
sites anchored, 145 anchors resolve.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sharing: turning publicSharing off leaves already-minted links serving — and silently drops the object's redactFields

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-sharing): declared publicSharing.redactFields survive the object opting out - #14171

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-13856-redactfields-survive-optout
Sep 1, 2026
Merged

fix(plugin-sharing): declared publicSharing.redactFields survive the object opting out#14171
os-steve merged 3 commits into
mainfrom
claude/issue-13856-redactfields-survive-optout

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#13856

What

ShareLinkService.getPolicy() returned an EMPTY policy — redactFields: [] included — whenever the object's publicSharing block was absent or enabled !== true. resolveToken() computes the served redaction set as policy.redactFields ∪ the per-link redact_fields, so a link minted while the object was opted IN and redeemed after it was opted OUT kept resolving and started serving the very fields the object declares redacted: turning the feature OFF made the anonymous endpoint serve MORE data than it did while the feature was ON.

The fix: the disabled branch of getPolicy now reads the declared redactFields from the object's declared block regardless of enabled, so switching the feature off can never widen what an existing token serves. resolveToken is the only consumer of policy.redactFields (createLink never reads it), so the seam is exactly the served set.

Scope fence

Behavioural reproduction (the card was a source reading — "no test was run for it")

Measured pre-fix on this branch's base, which contains #13857's rework (loadRecordForServing / stillEligible present):

  • Mint on an object declaring redactFields: ['owner_id','cost'], per-link redactFields: ['body']; redeem while ON: served redaction set ["owner_id","cost","body"].
  • Flip publicSharing.enabled to false; redeem again:
    • claim (a) the link still resolves: true
    • claim (b) the redaction set collapsed: true["body"] (declared set gone; per-link only)

Both card claims hold post-#13857; premise valid.

Verification (at 85fb05e55 unless noted)

New tests (share-link-service.test.ts, [#13856] block):

  • THE REPRO — declared redactions still apply after opt-out, plus the directional assertion the card asks for: fields served with the switch OFF are a subset of fields served with it ON (asserted as "fields served ONLY after opting out — must be none", i.e. servedOff minus servedOn is empty).
  • boundary — off-path redaction set is exactly declared ∪ per-link ({'owner_id','cost','body'}).
  • control — enabled: true serves exactly declared ∪ per-link. Green in both directions ⇒ declared control, not ablation evidence.
  • reverse controls — an object with no publicSharing block sprouts NO redaction set out of this change; per-link-only redaction still applies there.
  • mint gate — enabled: false still refuses with code: SHARING_NOT_ENABLED, status: 422 (ADR-0112 envelope assertion, not bare toThrow).

Reverse verification of the tests (pre-fix run): exactly the 2 predicted reds — expected [ 'body' ] to include 'owner_id' and expected Set{ 'body' } to deeply equal Set{ 'owner_id', 'cost', 'body' } — 24/26 green.

Ablation from the committed fix. The subject resolves via the relative ./share-link-service.js src import under vitest transform — not via package exports/dist — so no build leg applies to either leg (stated, not skipped). Direction predicted first: exactly 2 reds (THE REPRO, boundary).

  • Mutation proven on disk: marker counts — removed-text raw?.redactFields 1 to 0, injected ABLATION-13856 0 to 1; worktree blob 32a9e6988… changed to 5d8f78cd9… (git hash-object, non-empty).
  • Observed: Tests 2 failed | 24 passed (26) — exactly the predicted two.
  • Restore proven by state: blob back to the HEAD blob 32a9e6988…, git diff HEAD empty, marker counts back to 1 / 0.

Package union at 85fb05e55 via the verify-lock entry point (its own printed verdict: VERDICT command-exit 0): vitest Test Files 30 passed (30) · Tests 705 passed (705) — includes the #13608 redemption-eligibility pins and the #7861/#8489/#9085 suites (boundary requirement: that gate untouched and green) — plus package typecheck green.

tsc program membership (tsc --noEmit --listFiles): src/share-link-service.ts in the program (1 hit). NOT MEASURED: the test file's types — the package tsconfig and tsconfig.scripts.json both exclude *.test.ts (0 hits in both programs); standing package convention, and both halves of the coverage gate are green (check:type-check-coverage OK; check:type-check-debt --re-measure: 29 entries, none above recorded).

Gate family re-derived from the actual diff (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, no path args; derivation stderr named this repo at 85fb05e55; both output sections read whole): 36 harvested families run, every exit captured before any pipe. All green except one:

  • check-test-completeness = NOT MEASURED by the gate's own documented local branch — it grades a CI-saved turbo run test log and instructs "running the family locally, record this gate as NOT MEASURED". CI supplies the log on every PR.
  • check:i18n and check:dual-build-cjs-loads first refused loudly (PREREQUISITE NOT MET, exit 3 ≠ red); green after their named closure builds — check-i18n-bundles: OK (9 package(s)), dual-build floors 90/58/520/1 vs this run 102/66/610/1.
  • check:nul-bytes green; control-character self-scan of all three edited files: no hits.

Fixture sweep: no existing fixture anywhere in the repo pins the disabled+redactFields combination (all publicSharing referents checked) — which is why the defect had no reading; the new tests are an addition, not a rewrite of a standing assertion.

Declared narrowing: pnpm lint (repo-wide ESLint) is CI-owned and was not run locally.

Clause-②

Clause-②: yes — this changes what the anonymous share-link endpoint actually serves (a deployment-visible narrowing on opted-out objects). Surface touched:

  • packages/plugins/plugin-sharing/src/share-link-service.ts (the getPolicy disabled branch only)
  • packages/plugins/plugin-sharing/src/share-link-service.test.ts
  • .changeset/share-link-redactfields-survive-optout.md (patch, declares the narrowing)

No packages/spec/** surface touched; no schema accept/reject change.

Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs

Generated by Claude Code


Generated by Claude Code

…ommit)
The dispatched dev agent was terminated mid-task by a session rate limit
(HTTP 429) before it could commit or push. This commit preserves what was on
disk so the container's reclamation cannot lose it.
NOT reviewed and NOT verified: no suite was run against this state, and the
work is incomplete by the agent's own account (it was still re-acquiring the
shared verify lock for its reproduction leg). Treat as a starting point to
amend, never as a finished change.
Deliberately excluded: repro-13856.scratch.test.ts, which the agent named as
scratch and plainly did not intend to ship.
…object opting out
getPolicy() collapsed to an empty policy whenever the object's publicSharing
block had enabled !== true — redactFields: [] included — so a link minted
while the object was opted in, redeemed after it was opted out, kept resolving
and started serving the very fields the object declares redacted. The declared
redaction set is now read from the declared block regardless of enabled, so
opting out can never widen what an existing token serves. The mint-time gate,
the redemption-time eligibility gate, the per-link union, and the no-block
path are unchanged; whether standing links should resolve at all after opt-out
is a separate pending ruling and is deliberately not implemented here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

github-actionsBot commented Sep 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 listnot 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 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 8 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 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from 802bca641b4d97366ffe5cc2baf1017c3fc264cb — the merge of head ab98ef31008845fb99874950211013ba5f0b5838 into base 45b9051248f86f362b042fa9de63295a8c224073, 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 802bca641b4d97366ffe5cc2baf1017c3fc264cb && git checkout 802bca641b4d97366ffe5cc2baf1017c3fc264cb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 45b9051248f86f362b042fa9de63295a8c224073 ab98ef31008845fb99874950211013ba5f0b5838 && git checkout -B drift-repro 45b9051248f86f362b042fa9de63295a8c224073 && git merge --no-ff ab98ef31008845fb99874950211013ba5f0b5838
node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

契约复审:PASS —— 逐字采纳,双载体标签已清

转录核验:

assistant 轮次: 48
盖章 "claude-fable-5": 48
非 fable 盖章(回退证据): 0

⇒ 每轮在档、零回退 ⇒ 逐字采纳。以下为复审席原文,⛔ 未改写、未删节、未润色:


契约复审:PASS

结论一行

退订后声明红act集单向收窄、无一路径放宽,#14033/#13608 栅栏完好,可照发。

逐题回答

Q1 — 每条路径都严格收窄:是。 证据链(全部读自 FETCH_HEAD = 85fb05e,即 PR head):

  • 全文件 old↔new diff 只有一个 hunk(share-link-service.ts:103,redactFields: []Array.isArray(raw?.redactFields) ? … : [])。三种输入形态逐一核过:无 publicSharing 块 → raw?.redactFields 为 undefined → [],与旧代码逐字节同值(:114);enabled !== true 且声明了数组 → 由 [] 变为声明集,是旧值的超集;enabled: true 分支(:117-125)逐字节未动。即 policy.redactFields(new) ⊇ policy.redactFields(old) 对一切输入成立
  • policy.redactFields 全仓只有一个消费点:share-link-service.ts:698new Set([...policy.redactFields, ...row.redact_fields ?? []]) —— 集合并,单调递增;逐-link redact_fields(含 null → ?? [])一侧不变。
  • 两个服务边沿的红act都是减法:share-link-routes.ts:119-126(applyRedactionincludes(k) continue 跳键复制)+ :278;runtime/src/domains/share-links.ts:125-133 + :176。红act集越大,served 键严格越少。
  • redactFields 不参与任何 serve/refuse 判定:resolveToken 的全部拒绝点(token 形状、行查找、revoked/expired、audience、password、存在性探针 :662-667、eligibility :694)均不读它;loadRecordForServing 的投影加宽只以 Boolean(eligibility) 为键(:665),而 disabled 分支上 eligibility 前后都是 undefined(:636)。
  • messages 子端点(share-link-routes.ts:313-342runtime/.../share-links.ts:193-212)前后都不消费 redactFields,该面集合不变。

结论:开关 OFF 时 served 集 ⊆ ON 时 served 集,且 new-served ⊆ old-served,零放宽。唯一多流出比特的通道是响应封皮里的 redactFields 键本身(字段,见非阻塞),不属记录字段。

Q2 — 范围栅栏是真的:是。 变更文件恰为 3 个(changeset、test、service,git diff --name-only 实测);service 全文件 diff 单 hunk,故 resolveToken 的 serve 判定(:600-716)、#13608 的门表达式 policy.enabled ? policy.eligibility : undefined(:483、:636)与 if (eligibility && !this.stillEligible(...)) return null(:694)、mint 侧 SHARING_NOT_ENABLED 422(:434-440)全部逐字节未动。警戒过的"悄悄改变块是否 in force":policy.enabled 仍是唯一 in-force 信号(:434/:443/:452/:483/:636),而"声明即算数、disabled 也算"在 record-share-cascade.ts:168,187 本就是先例且未被触碰。#14033 两个方向都没实现——测试里明写 resolve-通过是 given 而非 pin。

Q3 — disabled 时"声明块"的含义,连贯且有边界。 半生效是有意的且边界可陈述:(a) spec 对 redactFields 的既有文本(object.zod.ts:2251-2258)本就是"stripped from every response served via a share token, regardless of audience"——serve 时无条件;新代码符合 spec 文字,旧代码违背它。(b) 兄弟键里唯一在赎回时有力的是 eligibility,它仍被 enabled 门着(:636)——那正是 #14033 圈起的 serve/refuse 问题,changeset 已在"Unchanged, deliberately"里点名。(c) mint 时键(allowedAudiences/Permissions/maxExpiryDays)只在 permissive/isSystem 才可能于 disabled 下被触达,那条路整块统一不咨询——先例行为,未动。边界因此是:投影键在退订后存活;裁决键等 #14033。:103-113 的注释把方向说清了;下一个新键须按此归类,现有陈述足以承载。

Q4 — 发布形状零变化。ResolveShareLinkResult(spec/src/contracts/share-link-service.ts:111-115)未触碰,仍 { link, redactFields: string[] };getPolicy 两分支签名同为 redactFields: string[](:89-96)。另一个调用者 createLink 读的是 enabled/allowedPermissions/allowedAudiences/eligibility/maxExpiryDays(:434/:443/:452/:483/:512),从不读 policy.redactFields,故空策略契约对它无变化。附带:contracts:113 的注释"object default ∪ per-link"在旧代码的 disabled 路径上其实是假话,本修后才为真——形状不变,诚实度上升。

Q5 — 无需制造。 无阻塞级新发现。

tsc 边界(核实,非采信):属实。plugin-sharing/tsconfig.json include src/**/* 但 exclude **/*.test.ts;tsconfig.scripts.json 只 include scripts/**/*;package.json:18 的 typecheck 恰为这两个 program;vitest.config.tstypecheck 项,vitest 的 transform 只剥类型不查类型。含义要说白:新增 154 行测试不被任何 tsc program 读过,包级 typecheck 绿灯对它们的类型零作证;其唯一验证是 vitest 运行时执行。绿 typecheck 不得被当作测试类型覆盖引用。(dev 的 --listFiles 0/1 计数本身我未复跑——结构读法独立推出同一结论。)

非阻塞

  1. 退订对象的匿名赎回,响应封皮 redactFields 键现在会列出声明集的字段名(share-link-routes.ts:290runtime/.../share-links.ts:188)——旧行为只列逐-link 名。这是既有发布形状(contracts:113"Effective fields removed")的必然产物,且严格优于它取代的字段外泄;若要清账,一行即可:在 changeset 的声明清单里补记这一元数据面。不动代码。

我核了什么 / 我没核什么

核了:PR head 85fb05e 与 merge-base 的全文件 diff(service 单 hunk、全仓仅 3 文件);getPolicy 仅两个调用点、policy.redactFields 仅一个消费点;两个服务边沿的减法红act;两处 messages 端点不消费红act集;record-share-cascade 的"声明即算数"先例未动;spec 的 publicSharing 块全部六键及其文本;ResolveShareLinkResult 未动;测试 harness getSchema 按引用返回(share-link-service.test.ts:23),enabled 翻转确实能被服务观察到;tsconfig 双 program 的 include/exclude。

没核(NOT MEASURED):新测试的实际执行与 30 files / 705 tests 绿灯——由 CI 度量,dev 报绿;dev 的行为学复现([owner_id, cost, body] → [body])未复跑;tsc --listFiles 计数未复跑(结构证据等价)。以上任何一项都未被我当作绿色报告。


PM 侧收口

⭐ 这份裁决里最值钱的是 Q1 的结构性证明,不是测试

它没有停在「测试断言了子集方向」,而是从代码把单调性推了出来:单 hunk、三种输入形态逐一比对得 redactFields(new) ⊇ redactFields(old);全仓唯一消费点是一个集合(单调);两个服务边沿都是减法(红act集越大、served 键越少);且 redactFields不参与任何 serve/refuse 判定。⇒ 「零放宽」是被证明的,不是被抽样的。⛔ 一个只由测试支撑的方向性主张,永远只覆盖被想到的那些输入。

⚠️ 非阻塞项本席采纳,但记一句它为什么不阻塞

退订对象的响应封皮现在会列出声明集的字段名(旧行为只列逐-link 名)。这确实多流出了一点元数据。但:(a) 它是既有发布形状 redactFields: "Effective fields removed" 的必然产物,不是本 PR 新造的面;(b) 它换掉的是这些字段的本身外泄。⇒ 用字段名换字段值,是严格改善。复审席的建议(在 changeset 声明清单里补记这一元数据面)是账目问题,不改代码 —— 留给下一张动到该 changeset 的卡,或发布时一并记。

⚠️ tsc 边界照实记在账上

新增的 154 行测试不在任何 tsc program 里(包 tsconfig 排除 **/*.test.ts,vitest 只剥类型不查类型)。⇒ 包级 typecheck 绿灯对这些测试的类型零作证,它们唯一的验证是 vitest 的运行时执行。⛔ 这条不得被后来者当作"测试类型也过了"引用。


Generated by Claude Code

…shifted it
`check-system-context-census` failed on this branch: 10 problems over 145
anchors and 109 census sites — 5 `[anchor-is-not-a-read-site]` plus the 5
`[site-without-a-row]` reads they had rotted off.
Cause: this PR's single hunk in `getPolicy`'s disabled branch (the
`redactFields` ternary and its explaining comment, +12/-1 at line ~103)
pushed every `isSystem` read site below it down by exactly 11 lines, so
row 37's citations in `content/docs/permissions/system-context.mdx` no
longer resolved.
Pure line rot, not a population change — the read-site count is unchanged
at 109, each stale anchor pairs 1:1 with an orphaned site at a uniform
delta of +11, and the old and new lines are byte-identical:
:423 -> :434 :477 -> :488 :481 -> :492 :554 -> :565 :584 -> :595
Repaired with `node scripts/check-system-context-census.mjs --fix`, which
rewrote 5 anchors and REFUSED nothing. No anchor was hand-edited; no
source or test file is touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

check-system-context-census repaired — pure line rot, --fix refused nothing

Lint & Repo Gates was red on check-system-context-census at 85fb05e55: 10 problems over 145 anchors and 109 census sites — 5 [anchor-is-not-a-read-site] plus the 5 [site-without-a-row] reads they had rotted off.

Cause. This PR's single hunk in getPolicy's disabled branch (the redactFields ternary plus its explaining comment, +12/-1 at line ~103) pushed every isSystem read site below it down. Row 37 of content/docs/permissions/system-context.mdx still cited the old lines.

This was a pure shift, not a population change — verified before invoking --fix, not assumed from the earlier #13934 repair:

stale anchororphaned read sitedeltaold vs new line
:423:434+11byte-identical
:477:488+11byte-identical
:481:492+11byte-identical
:554:565+11byte-identical
:584:595+11byte-identical

Cardinality 5 to 5, uniform delta, and each old anchor line at HEAD~1 is byte-identical to its new site line at HEAD. Site count is unchanged at 109.

What --fix did.node scripts/check-system-context-census.mjs --fix, exit 0:

 re-anchored content/docs/permissions/system-context.mdx:138 `plugin-sharing/src/share-link-service.ts:423` -> `plugin-sharing/src/share-link-service.ts:434`
re-anchored content/docs/permissions/system-context.mdx:138 `:477` -> `:488`
re-anchored content/docs/permissions/system-context.mdx:138 `:481` -> `:492`
re-anchored content/docs/permissions/system-context.mdx:138 `:554` -> `:565`
re-anchored content/docs/permissions/system-context.mdx:138 `:584` -> `:595`
check-system-context-census --fix: 5 anchor(s) rewritten

It REFUSED nothing. A refusal prints NOT fixable: on stdout; that line count in the captured output is 0, against 5 re-anchored lines. This is a positive reading rather than merely absent text: the same run's --self-test exercises FIX: a population change is REFUSED, never guessed and four further #13490 refusal cases, all ok, so the refusal machinery was live and silent here.

Confirming re-runs, at the final commit ab98ef310:

  • node scripts/check-system-context-census.mjs — exit 0: OK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read.
  • node scripts/check-system-context-census.mjs --self-test — exit 0: all cases passed
  • git diff --statone file: content/docs/permissions/system-context.mdx | 2 +-. Only the five line numbers in row 37 changed; the row's prose is untouched. No anchor was hand-edited, and share-link-service.ts and its test are untouched.

Also green on this diff: check:doc-anchors, check:doc-frontmatter, check:doc-route-spelling, check:docs-section-name, check:section-landing-index, check:docs-single-h1, check:docs-redirects, check:docs-audit-scope, check:corpus-claim-drift, check:role-word, check:doc-authoring, check:keyed-text-bounds, check:nul-bytes.

Why no local run could have caught this

check-system-context-census is not derivable from this PR's diff. node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, run on the clean tree at 85fb05e55 before any edit, does not name it — it scores silent, the weakest verdict:

- node scripts/check-system-context-census.mjs [lint.yml] names: content/docs/permissions/system-context.mdx, packages/spec/src/kernel/execution-context.zod.ts, packages/spec/src/data/object.zod.ts, …

The gate's declared literals are the page plus a 29-entry NON_READ_ANCHORS roster; packages/plugins/plugin-sharing/src/share-link-service.ts appears nowhere in the gate source. Its real population is resolved at runtime — git ls-files packages examples in isystem-census.mjs. So a diff touching only packages/plugins/plugin-sharing/src/** can never pull this family in, while CI schedules it unconditionally. Reported for triage; no card filed from here.


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 10:43
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 67192ceSep 1, 2026
35 checks passed
@os-steve
os-steve deleted the claude/issue-13856-redactfields-survive-optout branch September 1, 2026 11:05
os-support-ai pushed a commit that referenced this pull request Sep 1, 2026
The auto-merge of origin/main kept this branch's pre-#14171 anchor rows
beside the row-21 edit, flunking the census 26 ways (paired stale
anchors / unanchored sites in engine.ts and share-link-service.ts —
pure adjacent-row line rot, zero population change). Resolution per the
tool, not by hand: took main's page wholesale, then re-derived with
check-system-context-census --fix, which rewrote exactly ONE anchor
(row 21, protocol.ts 1737 to 1741 — this branch's publisher-block
shift). Delta vs origin/main is that single line; bare gate green: 109
sites anchored, 145 anchors resolve.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sharing: turning publicSharing off leaves already-minted links serving — and silently drops the object's redactFields

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-sharing): declared publicSharing.redactFields survive the object opting out - #14171

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-13856-redactfields-survive-optout
Sep 1, 2026
Merged

fix(plugin-sharing): declared publicSharing.redactFields survive the object opting out#14171
os-steve merged 3 commits into
mainfrom
claude/issue-13856-redactfields-survive-optout

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#13856

What

ShareLinkService.getPolicy() returned an EMPTY policy — redactFields: [] included — whenever the object's publicSharing block was absent or enabled !== true. resolveToken() computes the served redaction set as policy.redactFields ∪ the per-link redact_fields, so a link minted while the object was opted IN and redeemed after it was opted OUT kept resolving and started serving the very fields the object declares redacted: turning the feature OFF made the anonymous endpoint serve MORE data than it did while the feature was ON.

The fix: the disabled branch of getPolicy now reads the declared redactFields from the object's declared block regardless of enabled, so switching the feature off can never widen what an existing token serves. resolveToken is the only consumer of policy.redactFields (createLink never reads it), so the seam is exactly the served set.

Scope fence

Behavioural reproduction (the card was a source reading — "no test was run for it")

Measured pre-fix on this branch's base, which contains #13857's rework (loadRecordForServing / stillEligible present):

  • Mint on an object declaring redactFields: ['owner_id','cost'], per-link redactFields: ['body']; redeem while ON: served redaction set ["owner_id","cost","body"].
  • Flip publicSharing.enabled to false; redeem again:
    • claim (a) the link still resolves: true
    • claim (b) the redaction set collapsed: true["body"] (declared set gone; per-link only)

Both card claims hold post-#13857; premise valid.

Verification (at 85fb05e55 unless noted)

New tests (share-link-service.test.ts, [#13856] block):

  • THE REPRO — declared redactions still apply after opt-out, plus the directional assertion the card asks for: fields served with the switch OFF are a subset of fields served with it ON (asserted as "fields served ONLY after opting out — must be none", i.e. servedOff minus servedOn is empty).
  • boundary — off-path redaction set is exactly declared ∪ per-link ({'owner_id','cost','body'}).
  • control — enabled: true serves exactly declared ∪ per-link. Green in both directions ⇒ declared control, not ablation evidence.
  • reverse controls — an object with no publicSharing block sprouts NO redaction set out of this change; per-link-only redaction still applies there.
  • mint gate — enabled: false still refuses with code: SHARING_NOT_ENABLED, status: 422 (ADR-0112 envelope assertion, not bare toThrow).

Reverse verification of the tests (pre-fix run): exactly the 2 predicted reds — expected [ 'body' ] to include 'owner_id' and expected Set{ 'body' } to deeply equal Set{ 'owner_id', 'cost', 'body' } — 24/26 green.

Ablation from the committed fix. The subject resolves via the relative ./share-link-service.js src import under vitest transform — not via package exports/dist — so no build leg applies to either leg (stated, not skipped). Direction predicted first: exactly 2 reds (THE REPRO, boundary).

  • Mutation proven on disk: marker counts — removed-text raw?.redactFields 1 to 0, injected ABLATION-13856 0 to 1; worktree blob 32a9e6988… changed to 5d8f78cd9… (git hash-object, non-empty).
  • Observed: Tests 2 failed | 24 passed (26) — exactly the predicted two.
  • Restore proven by state: blob back to the HEAD blob 32a9e6988…, git diff HEAD empty, marker counts back to 1 / 0.

Package union at 85fb05e55 via the verify-lock entry point (its own printed verdict: VERDICT command-exit 0): vitest Test Files 30 passed (30) · Tests 705 passed (705) — includes the #13608 redemption-eligibility pins and the #7861/#8489/#9085 suites (boundary requirement: that gate untouched and green) — plus package typecheck green.

tsc program membership (tsc --noEmit --listFiles): src/share-link-service.ts in the program (1 hit). NOT MEASURED: the test file's types — the package tsconfig and tsconfig.scripts.json both exclude *.test.ts (0 hits in both programs); standing package convention, and both halves of the coverage gate are green (check:type-check-coverage OK; check:type-check-debt --re-measure: 29 entries, none above recorded).

Gate family re-derived from the actual diff (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, no path args; derivation stderr named this repo at 85fb05e55; both output sections read whole): 36 harvested families run, every exit captured before any pipe. All green except one:

  • check-test-completeness = NOT MEASURED by the gate's own documented local branch — it grades a CI-saved turbo run test log and instructs "running the family locally, record this gate as NOT MEASURED". CI supplies the log on every PR.
  • check:i18n and check:dual-build-cjs-loads first refused loudly (PREREQUISITE NOT MET, exit 3 ≠ red); green after their named closure builds — check-i18n-bundles: OK (9 package(s)), dual-build floors 90/58/520/1 vs this run 102/66/610/1.
  • check:nul-bytes green; control-character self-scan of all three edited files: no hits.

Fixture sweep: no existing fixture anywhere in the repo pins the disabled+redactFields combination (all publicSharing referents checked) — which is why the defect had no reading; the new tests are an addition, not a rewrite of a standing assertion.

Declared narrowing: pnpm lint (repo-wide ESLint) is CI-owned and was not run locally.

Clause-②

Clause-②: yes — this changes what the anonymous share-link endpoint actually serves (a deployment-visible narrowing on opted-out objects). Surface touched:

  • packages/plugins/plugin-sharing/src/share-link-service.ts (the getPolicy disabled branch only)
  • packages/plugins/plugin-sharing/src/share-link-service.test.ts
  • .changeset/share-link-redactfields-survive-optout.md (patch, declares the narrowing)

No packages/spec/** surface touched; no schema accept/reject change.

Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs

Generated by Claude Code


Generated by Claude Code

…ommit)
The dispatched dev agent was terminated mid-task by a session rate limit
(HTTP 429) before it could commit or push. This commit preserves what was on
disk so the container's reclamation cannot lose it.
NOT reviewed and NOT verified: no suite was run against this state, and the
work is incomplete by the agent's own account (it was still re-acquiring the
shared verify lock for its reproduction leg). Treat as a starting point to
amend, never as a finished change.
Deliberately excluded: repro-13856.scratch.test.ts, which the agent named as
scratch and plainly did not intend to ship.
…object opting out
getPolicy() collapsed to an empty policy whenever the object's publicSharing
block had enabled !== true — redactFields: [] included — so a link minted
while the object was opted in, redeemed after it was opted out, kept resolving
and started serving the very fields the object declares redacted. The declared
redaction set is now read from the declared block regardless of enabled, so
opting out can never widen what an existing token serves. The mint-time gate,
the redemption-time eligibility gate, the per-link union, and the no-block
path are unchanged; whether standing links should resolve at all after opt-out
is a separate pending ruling and is deliberately not implemented here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

github-actionsBot commented Sep 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 listnot 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 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 8 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 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from 802bca641b4d97366ffe5cc2baf1017c3fc264cb — the merge of head ab98ef31008845fb99874950211013ba5f0b5838 into base 45b9051248f86f362b042fa9de63295a8c224073, 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 802bca641b4d97366ffe5cc2baf1017c3fc264cb && git checkout 802bca641b4d97366ffe5cc2baf1017c3fc264cb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 45b9051248f86f362b042fa9de63295a8c224073 ab98ef31008845fb99874950211013ba5f0b5838 && git checkout -B drift-repro 45b9051248f86f362b042fa9de63295a8c224073 && git merge --no-ff ab98ef31008845fb99874950211013ba5f0b5838
node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

契约复审:PASS —— 逐字采纳,双载体标签已清

转录核验:

assistant 轮次: 48
盖章 "claude-fable-5": 48
非 fable 盖章(回退证据): 0

⇒ 每轮在档、零回退 ⇒ 逐字采纳。以下为复审席原文,⛔ 未改写、未删节、未润色:


契约复审:PASS

结论一行

退订后声明红act集单向收窄、无一路径放宽,#14033/#13608 栅栏完好,可照发。

逐题回答

Q1 — 每条路径都严格收窄:是。 证据链(全部读自 FETCH_HEAD = 85fb05e,即 PR head):

  • 全文件 old↔new diff 只有一个 hunk(share-link-service.ts:103,redactFields: []Array.isArray(raw?.redactFields) ? … : [])。三种输入形态逐一核过:无 publicSharing 块 → raw?.redactFields 为 undefined → [],与旧代码逐字节同值(:114);enabled !== true 且声明了数组 → 由 [] 变为声明集,是旧值的超集;enabled: true 分支(:117-125)逐字节未动。即 policy.redactFields(new) ⊇ policy.redactFields(old) 对一切输入成立
  • policy.redactFields 全仓只有一个消费点:share-link-service.ts:698new Set([...policy.redactFields, ...row.redact_fields ?? []]) —— 集合并,单调递增;逐-link redact_fields(含 null → ?? [])一侧不变。
  • 两个服务边沿的红act都是减法:share-link-routes.ts:119-126(applyRedactionincludes(k) continue 跳键复制)+ :278;runtime/src/domains/share-links.ts:125-133 + :176。红act集越大,served 键严格越少。
  • redactFields 不参与任何 serve/refuse 判定:resolveToken 的全部拒绝点(token 形状、行查找、revoked/expired、audience、password、存在性探针 :662-667、eligibility :694)均不读它;loadRecordForServing 的投影加宽只以 Boolean(eligibility) 为键(:665),而 disabled 分支上 eligibility 前后都是 undefined(:636)。
  • messages 子端点(share-link-routes.ts:313-342runtime/.../share-links.ts:193-212)前后都不消费 redactFields,该面集合不变。

结论:开关 OFF 时 served 集 ⊆ ON 时 served 集,且 new-served ⊆ old-served,零放宽。唯一多流出比特的通道是响应封皮里的 redactFields 键本身(字段,见非阻塞),不属记录字段。

Q2 — 范围栅栏是真的:是。 变更文件恰为 3 个(changeset、test、service,git diff --name-only 实测);service 全文件 diff 单 hunk,故 resolveToken 的 serve 判定(:600-716)、#13608 的门表达式 policy.enabled ? policy.eligibility : undefined(:483、:636)与 if (eligibility && !this.stillEligible(...)) return null(:694)、mint 侧 SHARING_NOT_ENABLED 422(:434-440)全部逐字节未动。警戒过的"悄悄改变块是否 in force":policy.enabled 仍是唯一 in-force 信号(:434/:443/:452/:483/:636),而"声明即算数、disabled 也算"在 record-share-cascade.ts:168,187 本就是先例且未被触碰。#14033 两个方向都没实现——测试里明写 resolve-通过是 given 而非 pin。

Q3 — disabled 时"声明块"的含义,连贯且有边界。 半生效是有意的且边界可陈述:(a) spec 对 redactFields 的既有文本(object.zod.ts:2251-2258)本就是"stripped from every response served via a share token, regardless of audience"——serve 时无条件;新代码符合 spec 文字,旧代码违背它。(b) 兄弟键里唯一在赎回时有力的是 eligibility,它仍被 enabled 门着(:636)——那正是 #14033 圈起的 serve/refuse 问题,changeset 已在"Unchanged, deliberately"里点名。(c) mint 时键(allowedAudiences/Permissions/maxExpiryDays)只在 permissive/isSystem 才可能于 disabled 下被触达,那条路整块统一不咨询——先例行为,未动。边界因此是:投影键在退订后存活;裁决键等 #14033。:103-113 的注释把方向说清了;下一个新键须按此归类,现有陈述足以承载。

Q4 — 发布形状零变化。ResolveShareLinkResult(spec/src/contracts/share-link-service.ts:111-115)未触碰,仍 { link, redactFields: string[] };getPolicy 两分支签名同为 redactFields: string[](:89-96)。另一个调用者 createLink 读的是 enabled/allowedPermissions/allowedAudiences/eligibility/maxExpiryDays(:434/:443/:452/:483/:512),从不读 policy.redactFields,故空策略契约对它无变化。附带:contracts:113 的注释"object default ∪ per-link"在旧代码的 disabled 路径上其实是假话,本修后才为真——形状不变,诚实度上升。

Q5 — 无需制造。 无阻塞级新发现。

tsc 边界(核实,非采信):属实。plugin-sharing/tsconfig.json include src/**/* 但 exclude **/*.test.ts;tsconfig.scripts.json 只 include scripts/**/*;package.json:18 的 typecheck 恰为这两个 program;vitest.config.tstypecheck 项,vitest 的 transform 只剥类型不查类型。含义要说白:新增 154 行测试不被任何 tsc program 读过,包级 typecheck 绿灯对它们的类型零作证;其唯一验证是 vitest 运行时执行。绿 typecheck 不得被当作测试类型覆盖引用。(dev 的 --listFiles 0/1 计数本身我未复跑——结构读法独立推出同一结论。)

非阻塞

  1. 退订对象的匿名赎回,响应封皮 redactFields 键现在会列出声明集的字段名(share-link-routes.ts:290runtime/.../share-links.ts:188)——旧行为只列逐-link 名。这是既有发布形状(contracts:113"Effective fields removed")的必然产物,且严格优于它取代的字段外泄;若要清账,一行即可:在 changeset 的声明清单里补记这一元数据面。不动代码。

我核了什么 / 我没核什么

核了:PR head 85fb05e 与 merge-base 的全文件 diff(service 单 hunk、全仓仅 3 文件);getPolicy 仅两个调用点、policy.redactFields 仅一个消费点;两个服务边沿的减法红act;两处 messages 端点不消费红act集;record-share-cascade 的"声明即算数"先例未动;spec 的 publicSharing 块全部六键及其文本;ResolveShareLinkResult 未动;测试 harness getSchema 按引用返回(share-link-service.test.ts:23),enabled 翻转确实能被服务观察到;tsconfig 双 program 的 include/exclude。

没核(NOT MEASURED):新测试的实际执行与 30 files / 705 tests 绿灯——由 CI 度量,dev 报绿;dev 的行为学复现([owner_id, cost, body] → [body])未复跑;tsc --listFiles 计数未复跑(结构证据等价)。以上任何一项都未被我当作绿色报告。


PM 侧收口

⭐ 这份裁决里最值钱的是 Q1 的结构性证明,不是测试

它没有停在「测试断言了子集方向」,而是从代码把单调性推了出来:单 hunk、三种输入形态逐一比对得 redactFields(new) ⊇ redactFields(old);全仓唯一消费点是一个集合(单调);两个服务边沿都是减法(红act集越大、served 键越少);且 redactFields不参与任何 serve/refuse 判定。⇒ 「零放宽」是被证明的,不是被抽样的。⛔ 一个只由测试支撑的方向性主张,永远只覆盖被想到的那些输入。

⚠️ 非阻塞项本席采纳,但记一句它为什么不阻塞

退订对象的响应封皮现在会列出声明集的字段名(旧行为只列逐-link 名)。这确实多流出了一点元数据。但:(a) 它是既有发布形状 redactFields: "Effective fields removed" 的必然产物,不是本 PR 新造的面;(b) 它换掉的是这些字段的本身外泄。⇒ 用字段名换字段值,是严格改善。复审席的建议(在 changeset 声明清单里补记这一元数据面)是账目问题,不改代码 —— 留给下一张动到该 changeset 的卡,或发布时一并记。

⚠️ tsc 边界照实记在账上

新增的 154 行测试不在任何 tsc program 里(包 tsconfig 排除 **/*.test.ts,vitest 只剥类型不查类型)。⇒ 包级 typecheck 绿灯对这些测试的类型零作证,它们唯一的验证是 vitest 的运行时执行。⛔ 这条不得被后来者当作"测试类型也过了"引用。


Generated by Claude Code

…shifted it
`check-system-context-census` failed on this branch: 10 problems over 145
anchors and 109 census sites — 5 `[anchor-is-not-a-read-site]` plus the 5
`[site-without-a-row]` reads they had rotted off.
Cause: this PR's single hunk in `getPolicy`'s disabled branch (the
`redactFields` ternary and its explaining comment, +12/-1 at line ~103)
pushed every `isSystem` read site below it down by exactly 11 lines, so
row 37's citations in `content/docs/permissions/system-context.mdx` no
longer resolved.
Pure line rot, not a population change — the read-site count is unchanged
at 109, each stale anchor pairs 1:1 with an orphaned site at a uniform
delta of +11, and the old and new lines are byte-identical:
:423 -> :434 :477 -> :488 :481 -> :492 :554 -> :565 :584 -> :595
Repaired with `node scripts/check-system-context-census.mjs --fix`, which
rewrote 5 anchors and REFUSED nothing. No anchor was hand-edited; no
source or test file is touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

check-system-context-census repaired — pure line rot, --fix refused nothing

Lint & Repo Gates was red on check-system-context-census at 85fb05e55: 10 problems over 145 anchors and 109 census sites — 5 [anchor-is-not-a-read-site] plus the 5 [site-without-a-row] reads they had rotted off.

Cause. This PR's single hunk in getPolicy's disabled branch (the redactFields ternary plus its explaining comment, +12/-1 at line ~103) pushed every isSystem read site below it down. Row 37 of content/docs/permissions/system-context.mdx still cited the old lines.

This was a pure shift, not a population change — verified before invoking --fix, not assumed from the earlier #13934 repair:

stale anchororphaned read sitedeltaold vs new line
:423:434+11byte-identical
:477:488+11byte-identical
:481:492+11byte-identical
:554:565+11byte-identical
:584:595+11byte-identical

Cardinality 5 to 5, uniform delta, and each old anchor line at HEAD~1 is byte-identical to its new site line at HEAD. Site count is unchanged at 109.

What --fix did.node scripts/check-system-context-census.mjs --fix, exit 0:

 re-anchored content/docs/permissions/system-context.mdx:138 `plugin-sharing/src/share-link-service.ts:423` -> `plugin-sharing/src/share-link-service.ts:434`
re-anchored content/docs/permissions/system-context.mdx:138 `:477` -> `:488`
re-anchored content/docs/permissions/system-context.mdx:138 `:481` -> `:492`
re-anchored content/docs/permissions/system-context.mdx:138 `:554` -> `:565`
re-anchored content/docs/permissions/system-context.mdx:138 `:584` -> `:595`
check-system-context-census --fix: 5 anchor(s) rewritten

It REFUSED nothing. A refusal prints NOT fixable: on stdout; that line count in the captured output is 0, against 5 re-anchored lines. This is a positive reading rather than merely absent text: the same run's --self-test exercises FIX: a population change is REFUSED, never guessed and four further #13490 refusal cases, all ok, so the refusal machinery was live and silent here.

Confirming re-runs, at the final commit ab98ef310:

  • node scripts/check-system-context-census.mjs — exit 0: OK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read.
  • node scripts/check-system-context-census.mjs --self-test — exit 0: all cases passed
  • git diff --statone file: content/docs/permissions/system-context.mdx | 2 +-. Only the five line numbers in row 37 changed; the row's prose is untouched. No anchor was hand-edited, and share-link-service.ts and its test are untouched.

Also green on this diff: check:doc-anchors, check:doc-frontmatter, check:doc-route-spelling, check:docs-section-name, check:section-landing-index, check:docs-single-h1, check:docs-redirects, check:docs-audit-scope, check:corpus-claim-drift, check:role-word, check:doc-authoring, check:keyed-text-bounds, check:nul-bytes.

Why no local run could have caught this

check-system-context-census is not derivable from this PR's diff. node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, run on the clean tree at 85fb05e55 before any edit, does not name it — it scores silent, the weakest verdict:

- node scripts/check-system-context-census.mjs [lint.yml] names: content/docs/permissions/system-context.mdx, packages/spec/src/kernel/execution-context.zod.ts, packages/spec/src/data/object.zod.ts, …

The gate's declared literals are the page plus a 29-entry NON_READ_ANCHORS roster; packages/plugins/plugin-sharing/src/share-link-service.ts appears nowhere in the gate source. Its real population is resolved at runtime — git ls-files packages examples in isystem-census.mjs. So a diff touching only packages/plugins/plugin-sharing/src/** can never pull this family in, while CI schedules it unconditionally. Reported for triage; no card filed from here.


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 10:43
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 67192ceSep 1, 2026
35 checks passed
@os-steve
os-steve deleted the claude/issue-13856-redactfields-survive-optout branch September 1, 2026 11:05
os-support-ai pushed a commit that referenced this pull request Sep 1, 2026
The auto-merge of origin/main kept this branch's pre-#14171 anchor rows
beside the row-21 edit, flunking the census 26 ways (paired stale
anchors / unanchored sites in engine.ts and share-link-service.ts —
pure adjacent-row line rot, zero population change). Resolution per the
tool, not by hand: took main's page wholesale, then re-derived with
check-system-context-census --fix, which rewrote exactly ONE anchor
(row 21, protocol.ts 1737 to 1741 — this branch's publisher-block
shift). Delta vs origin/main is that single line; bare gate green: 109
sites anchored, 145 anchors resolve.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sharing: turning publicSharing off leaves already-minted links serving — and silently drops the object's redactFields

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(plugin-sharing): declared publicSharing.redactFields survive the object opting out - #14171

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-13856-redactfields-survive-optout
Sep 1, 2026
Merged

fix(plugin-sharing): declared publicSharing.redactFields survive the object opting out#14171
os-steve merged 3 commits into
mainfrom
claude/issue-13856-redactfields-survive-optout

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#13856

What

ShareLinkService.getPolicy() returned an EMPTY policy — redactFields: [] included — whenever the object's publicSharing block was absent or enabled !== true. resolveToken() computes the served redaction set as policy.redactFields ∪ the per-link redact_fields, so a link minted while the object was opted IN and redeemed after it was opted OUT kept resolving and started serving the very fields the object declares redacted: turning the feature OFF made the anonymous endpoint serve MORE data than it did while the feature was ON.

The fix: the disabled branch of getPolicy now reads the declared redactFields from the object's declared block regardless of enabled, so switching the feature off can never widen what an existing token serves. resolveToken is the only consumer of policy.redactFields (createLink never reads it), so the seam is exactly the served set.

Scope fence

Behavioural reproduction (the card was a source reading — "no test was run for it")

Measured pre-fix on this branch's base, which contains #13857's rework (loadRecordForServing / stillEligible present):

  • Mint on an object declaring redactFields: ['owner_id','cost'], per-link redactFields: ['body']; redeem while ON: served redaction set ["owner_id","cost","body"].
  • Flip publicSharing.enabled to false; redeem again:
    • claim (a) the link still resolves: true
    • claim (b) the redaction set collapsed: true["body"] (declared set gone; per-link only)

Both card claims hold post-#13857; premise valid.

Verification (at 85fb05e55 unless noted)

New tests (share-link-service.test.ts, [#13856] block):

  • THE REPRO — declared redactions still apply after opt-out, plus the directional assertion the card asks for: fields served with the switch OFF are a subset of fields served with it ON (asserted as "fields served ONLY after opting out — must be none", i.e. servedOff minus servedOn is empty).
  • boundary — off-path redaction set is exactly declared ∪ per-link ({'owner_id','cost','body'}).
  • control — enabled: true serves exactly declared ∪ per-link. Green in both directions ⇒ declared control, not ablation evidence.
  • reverse controls — an object with no publicSharing block sprouts NO redaction set out of this change; per-link-only redaction still applies there.
  • mint gate — enabled: false still refuses with code: SHARING_NOT_ENABLED, status: 422 (ADR-0112 envelope assertion, not bare toThrow).

Reverse verification of the tests (pre-fix run): exactly the 2 predicted reds — expected [ 'body' ] to include 'owner_id' and expected Set{ 'body' } to deeply equal Set{ 'owner_id', 'cost', 'body' } — 24/26 green.

Ablation from the committed fix. The subject resolves via the relative ./share-link-service.js src import under vitest transform — not via package exports/dist — so no build leg applies to either leg (stated, not skipped). Direction predicted first: exactly 2 reds (THE REPRO, boundary).

  • Mutation proven on disk: marker counts — removed-text raw?.redactFields 1 to 0, injected ABLATION-13856 0 to 1; worktree blob 32a9e6988… changed to 5d8f78cd9… (git hash-object, non-empty).
  • Observed: Tests 2 failed | 24 passed (26) — exactly the predicted two.
  • Restore proven by state: blob back to the HEAD blob 32a9e6988…, git diff HEAD empty, marker counts back to 1 / 0.

Package union at 85fb05e55 via the verify-lock entry point (its own printed verdict: VERDICT command-exit 0): vitest Test Files 30 passed (30) · Tests 705 passed (705) — includes the #13608 redemption-eligibility pins and the #7861/#8489/#9085 suites (boundary requirement: that gate untouched and green) — plus package typecheck green.

tsc program membership (tsc --noEmit --listFiles): src/share-link-service.ts in the program (1 hit). NOT MEASURED: the test file's types — the package tsconfig and tsconfig.scripts.json both exclude *.test.ts (0 hits in both programs); standing package convention, and both halves of the coverage gate are green (check:type-check-coverage OK; check:type-check-debt --re-measure: 29 entries, none above recorded).

Gate family re-derived from the actual diff (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, no path args; derivation stderr named this repo at 85fb05e55; both output sections read whole): 36 harvested families run, every exit captured before any pipe. All green except one:

  • check-test-completeness = NOT MEASURED by the gate's own documented local branch — it grades a CI-saved turbo run test log and instructs "running the family locally, record this gate as NOT MEASURED". CI supplies the log on every PR.
  • check:i18n and check:dual-build-cjs-loads first refused loudly (PREREQUISITE NOT MET, exit 3 ≠ red); green after their named closure builds — check-i18n-bundles: OK (9 package(s)), dual-build floors 90/58/520/1 vs this run 102/66/610/1.
  • check:nul-bytes green; control-character self-scan of all three edited files: no hits.

Fixture sweep: no existing fixture anywhere in the repo pins the disabled+redactFields combination (all publicSharing referents checked) — which is why the defect had no reading; the new tests are an addition, not a rewrite of a standing assertion.

Declared narrowing: pnpm lint (repo-wide ESLint) is CI-owned and was not run locally.

Clause-②

Clause-②: yes — this changes what the anonymous share-link endpoint actually serves (a deployment-visible narrowing on opted-out objects). Surface touched:

  • packages/plugins/plugin-sharing/src/share-link-service.ts (the getPolicy disabled branch only)
  • packages/plugins/plugin-sharing/src/share-link-service.test.ts
  • .changeset/share-link-redactfields-survive-optout.md (patch, declares the narrowing)

No packages/spec/** surface touched; no schema accept/reject change.

Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs

Generated by Claude Code


Generated by Claude Code

…ommit)
The dispatched dev agent was terminated mid-task by a session rate limit
(HTTP 429) before it could commit or push. This commit preserves what was on
disk so the container's reclamation cannot lose it.
NOT reviewed and NOT verified: no suite was run against this state, and the
work is incomplete by the agent's own account (it was still re-acquiring the
shared verify lock for its reproduction leg). Treat as a starting point to
amend, never as a finished change.
Deliberately excluded: repro-13856.scratch.test.ts, which the agent named as
scratch and plainly did not intend to ship.
…object opting out
getPolicy() collapsed to an empty policy whenever the object's publicSharing
block had enabled !== true — redactFields: [] included — so a link minted
while the object was opted in, redeemed after it was opted out, kept resolving
and started serving the very fields the object declares redacted. The declared
redaction set is now read from the declared block regardless of enabled, so
opting out can never widen what an existing token serves. The mint-time gate,
the redemption-time eligibility gate, the per-link union, and the no-block
path are unchanged; whether standing links should resolve at all after opt-out
is a separate pending ruling and is deliberately not implemented here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

github-actionsBot commented Sep 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 listnot 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 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 8 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 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from 802bca641b4d97366ffe5cc2baf1017c3fc264cb — the merge of head ab98ef31008845fb99874950211013ba5f0b5838 into base 45b9051248f86f362b042fa9de63295a8c224073, 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 802bca641b4d97366ffe5cc2baf1017c3fc264cb && git checkout 802bca641b4d97366ffe5cc2baf1017c3fc264cb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 45b9051248f86f362b042fa9de63295a8c224073 ab98ef31008845fb99874950211013ba5f0b5838 && git checkout -B drift-repro 45b9051248f86f362b042fa9de63295a8c224073 && git merge --no-ff ab98ef31008845fb99874950211013ba5f0b5838
node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

契约复审:PASS —— 逐字采纳,双载体标签已清

转录核验:

assistant 轮次: 48
盖章 "claude-fable-5": 48
非 fable 盖章(回退证据): 0

⇒ 每轮在档、零回退 ⇒ 逐字采纳。以下为复审席原文,⛔ 未改写、未删节、未润色:


契约复审:PASS

结论一行

退订后声明红act集单向收窄、无一路径放宽,#14033/#13608 栅栏完好,可照发。

逐题回答

Q1 — 每条路径都严格收窄:是。 证据链(全部读自 FETCH_HEAD = 85fb05e,即 PR head):

  • 全文件 old↔new diff 只有一个 hunk(share-link-service.ts:103,redactFields: []Array.isArray(raw?.redactFields) ? … : [])。三种输入形态逐一核过:无 publicSharing 块 → raw?.redactFields 为 undefined → [],与旧代码逐字节同值(:114);enabled !== true 且声明了数组 → 由 [] 变为声明集,是旧值的超集;enabled: true 分支(:117-125)逐字节未动。即 policy.redactFields(new) ⊇ policy.redactFields(old) 对一切输入成立
  • policy.redactFields 全仓只有一个消费点:share-link-service.ts:698new Set([...policy.redactFields, ...row.redact_fields ?? []]) —— 集合并,单调递增;逐-link redact_fields(含 null → ?? [])一侧不变。
  • 两个服务边沿的红act都是减法:share-link-routes.ts:119-126(applyRedactionincludes(k) continue 跳键复制)+ :278;runtime/src/domains/share-links.ts:125-133 + :176。红act集越大,served 键严格越少。
  • redactFields 不参与任何 serve/refuse 判定:resolveToken 的全部拒绝点(token 形状、行查找、revoked/expired、audience、password、存在性探针 :662-667、eligibility :694)均不读它;loadRecordForServing 的投影加宽只以 Boolean(eligibility) 为键(:665),而 disabled 分支上 eligibility 前后都是 undefined(:636)。
  • messages 子端点(share-link-routes.ts:313-342runtime/.../share-links.ts:193-212)前后都不消费 redactFields,该面集合不变。

结论:开关 OFF 时 served 集 ⊆ ON 时 served 集,且 new-served ⊆ old-served,零放宽。唯一多流出比特的通道是响应封皮里的 redactFields 键本身(字段,见非阻塞),不属记录字段。

Q2 — 范围栅栏是真的:是。 变更文件恰为 3 个(changeset、test、service,git diff --name-only 实测);service 全文件 diff 单 hunk,故 resolveToken 的 serve 判定(:600-716)、#13608 的门表达式 policy.enabled ? policy.eligibility : undefined(:483、:636)与 if (eligibility && !this.stillEligible(...)) return null(:694)、mint 侧 SHARING_NOT_ENABLED 422(:434-440)全部逐字节未动。警戒过的"悄悄改变块是否 in force":policy.enabled 仍是唯一 in-force 信号(:434/:443/:452/:483/:636),而"声明即算数、disabled 也算"在 record-share-cascade.ts:168,187 本就是先例且未被触碰。#14033 两个方向都没实现——测试里明写 resolve-通过是 given 而非 pin。

Q3 — disabled 时"声明块"的含义,连贯且有边界。 半生效是有意的且边界可陈述:(a) spec 对 redactFields 的既有文本(object.zod.ts:2251-2258)本就是"stripped from every response served via a share token, regardless of audience"——serve 时无条件;新代码符合 spec 文字,旧代码违背它。(b) 兄弟键里唯一在赎回时有力的是 eligibility,它仍被 enabled 门着(:636)——那正是 #14033 圈起的 serve/refuse 问题,changeset 已在"Unchanged, deliberately"里点名。(c) mint 时键(allowedAudiences/Permissions/maxExpiryDays)只在 permissive/isSystem 才可能于 disabled 下被触达,那条路整块统一不咨询——先例行为,未动。边界因此是:投影键在退订后存活;裁决键等 #14033。:103-113 的注释把方向说清了;下一个新键须按此归类,现有陈述足以承载。

Q4 — 发布形状零变化。ResolveShareLinkResult(spec/src/contracts/share-link-service.ts:111-115)未触碰,仍 { link, redactFields: string[] };getPolicy 两分支签名同为 redactFields: string[](:89-96)。另一个调用者 createLink 读的是 enabled/allowedPermissions/allowedAudiences/eligibility/maxExpiryDays(:434/:443/:452/:483/:512),从不读 policy.redactFields,故空策略契约对它无变化。附带:contracts:113 的注释"object default ∪ per-link"在旧代码的 disabled 路径上其实是假话,本修后才为真——形状不变,诚实度上升。

Q5 — 无需制造。 无阻塞级新发现。

tsc 边界(核实,非采信):属实。plugin-sharing/tsconfig.json include src/**/* 但 exclude **/*.test.ts;tsconfig.scripts.json 只 include scripts/**/*;package.json:18 的 typecheck 恰为这两个 program;vitest.config.tstypecheck 项,vitest 的 transform 只剥类型不查类型。含义要说白:新增 154 行测试不被任何 tsc program 读过,包级 typecheck 绿灯对它们的类型零作证;其唯一验证是 vitest 运行时执行。绿 typecheck 不得被当作测试类型覆盖引用。(dev 的 --listFiles 0/1 计数本身我未复跑——结构读法独立推出同一结论。)

非阻塞

  1. 退订对象的匿名赎回,响应封皮 redactFields 键现在会列出声明集的字段名(share-link-routes.ts:290runtime/.../share-links.ts:188)——旧行为只列逐-link 名。这是既有发布形状(contracts:113"Effective fields removed")的必然产物,且严格优于它取代的字段外泄;若要清账,一行即可:在 changeset 的声明清单里补记这一元数据面。不动代码。

我核了什么 / 我没核什么

核了:PR head 85fb05e 与 merge-base 的全文件 diff(service 单 hunk、全仓仅 3 文件);getPolicy 仅两个调用点、policy.redactFields 仅一个消费点;两个服务边沿的减法红act;两处 messages 端点不消费红act集;record-share-cascade 的"声明即算数"先例未动;spec 的 publicSharing 块全部六键及其文本;ResolveShareLinkResult 未动;测试 harness getSchema 按引用返回(share-link-service.test.ts:23),enabled 翻转确实能被服务观察到;tsconfig 双 program 的 include/exclude。

没核(NOT MEASURED):新测试的实际执行与 30 files / 705 tests 绿灯——由 CI 度量,dev 报绿;dev 的行为学复现([owner_id, cost, body] → [body])未复跑;tsc --listFiles 计数未复跑(结构证据等价)。以上任何一项都未被我当作绿色报告。


PM 侧收口

⭐ 这份裁决里最值钱的是 Q1 的结构性证明,不是测试

它没有停在「测试断言了子集方向」,而是从代码把单调性推了出来:单 hunk、三种输入形态逐一比对得 redactFields(new) ⊇ redactFields(old);全仓唯一消费点是一个集合(单调);两个服务边沿都是减法(红act集越大、served 键越少);且 redactFields不参与任何 serve/refuse 判定。⇒ 「零放宽」是被证明的,不是被抽样的。⛔ 一个只由测试支撑的方向性主张,永远只覆盖被想到的那些输入。

⚠️ 非阻塞项本席采纳,但记一句它为什么不阻塞

退订对象的响应封皮现在会列出声明集的字段名(旧行为只列逐-link 名)。这确实多流出了一点元数据。但:(a) 它是既有发布形状 redactFields: "Effective fields removed" 的必然产物,不是本 PR 新造的面;(b) 它换掉的是这些字段的本身外泄。⇒ 用字段名换字段值,是严格改善。复审席的建议(在 changeset 声明清单里补记这一元数据面)是账目问题,不改代码 —— 留给下一张动到该 changeset 的卡,或发布时一并记。

⚠️ tsc 边界照实记在账上

新增的 154 行测试不在任何 tsc program 里(包 tsconfig 排除 **/*.test.ts,vitest 只剥类型不查类型)。⇒ 包级 typecheck 绿灯对这些测试的类型零作证,它们唯一的验证是 vitest 的运行时执行。⛔ 这条不得被后来者当作"测试类型也过了"引用。


Generated by Claude Code

…shifted it
`check-system-context-census` failed on this branch: 10 problems over 145
anchors and 109 census sites — 5 `[anchor-is-not-a-read-site]` plus the 5
`[site-without-a-row]` reads they had rotted off.
Cause: this PR's single hunk in `getPolicy`'s disabled branch (the
`redactFields` ternary and its explaining comment, +12/-1 at line ~103)
pushed every `isSystem` read site below it down by exactly 11 lines, so
row 37's citations in `content/docs/permissions/system-context.mdx` no
longer resolved.
Pure line rot, not a population change — the read-site count is unchanged
at 109, each stale anchor pairs 1:1 with an orphaned site at a uniform
delta of +11, and the old and new lines are byte-identical:
:423 -> :434 :477 -> :488 :481 -> :492 :554 -> :565 :584 -> :595
Repaired with `node scripts/check-system-context-census.mjs --fix`, which
rewrote 5 anchors and REFUSED nothing. No anchor was hand-edited; no
source or test file is touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

check-system-context-census repaired — pure line rot, --fix refused nothing

Lint & Repo Gates was red on check-system-context-census at 85fb05e55: 10 problems over 145 anchors and 109 census sites — 5 [anchor-is-not-a-read-site] plus the 5 [site-without-a-row] reads they had rotted off.

Cause. This PR's single hunk in getPolicy's disabled branch (the redactFields ternary plus its explaining comment, +12/-1 at line ~103) pushed every isSystem read site below it down. Row 37 of content/docs/permissions/system-context.mdx still cited the old lines.

This was a pure shift, not a population change — verified before invoking --fix, not assumed from the earlier #13934 repair:

stale anchororphaned read sitedeltaold vs new line
:423:434+11byte-identical
:477:488+11byte-identical
:481:492+11byte-identical
:554:565+11byte-identical
:584:595+11byte-identical

Cardinality 5 to 5, uniform delta, and each old anchor line at HEAD~1 is byte-identical to its new site line at HEAD. Site count is unchanged at 109.

What --fix did.node scripts/check-system-context-census.mjs --fix, exit 0:

 re-anchored content/docs/permissions/system-context.mdx:138 `plugin-sharing/src/share-link-service.ts:423` -> `plugin-sharing/src/share-link-service.ts:434`
re-anchored content/docs/permissions/system-context.mdx:138 `:477` -> `:488`
re-anchored content/docs/permissions/system-context.mdx:138 `:481` -> `:492`
re-anchored content/docs/permissions/system-context.mdx:138 `:554` -> `:565`
re-anchored content/docs/permissions/system-context.mdx:138 `:584` -> `:595`
check-system-context-census --fix: 5 anchor(s) rewritten

It REFUSED nothing. A refusal prints NOT fixable: on stdout; that line count in the captured output is 0, against 5 re-anchored lines. This is a positive reading rather than merely absent text: the same run's --self-test exercises FIX: a population change is REFUSED, never guessed and four further #13490 refusal cases, all ok, so the refusal machinery was live and silent here.

Confirming re-runs, at the final commit ab98ef310:

  • node scripts/check-system-context-census.mjs — exit 0: OK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read.
  • node scripts/check-system-context-census.mjs --self-test — exit 0: all cases passed
  • git diff --statone file: content/docs/permissions/system-context.mdx | 2 +-. Only the five line numbers in row 37 changed; the row's prose is untouched. No anchor was hand-edited, and share-link-service.ts and its test are untouched.

Also green on this diff: check:doc-anchors, check:doc-frontmatter, check:doc-route-spelling, check:docs-section-name, check:section-landing-index, check:docs-single-h1, check:docs-redirects, check:docs-audit-scope, check:corpus-claim-drift, check:role-word, check:doc-authoring, check:keyed-text-bounds, check:nul-bytes.

Why no local run could have caught this

check-system-context-census is not derivable from this PR's diff. node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, run on the clean tree at 85fb05e55 before any edit, does not name it — it scores silent, the weakest verdict:

- node scripts/check-system-context-census.mjs [lint.yml] names: content/docs/permissions/system-context.mdx, packages/spec/src/kernel/execution-context.zod.ts, packages/spec/src/data/object.zod.ts, …

The gate's declared literals are the page plus a 29-entry NON_READ_ANCHORS roster; packages/plugins/plugin-sharing/src/share-link-service.ts appears nowhere in the gate source. Its real population is resolved at runtime — git ls-files packages examples in isystem-census.mjs. So a diff touching only packages/plugins/plugin-sharing/src/** can never pull this family in, while CI schedules it unconditionally. Reported for triage; no card filed from here.


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 10:43
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 67192ceSep 1, 2026
35 checks passed
@os-steve
os-steve deleted the claude/issue-13856-redactfields-survive-optout branch September 1, 2026 11:05
os-support-ai pushed a commit that referenced this pull request Sep 1, 2026
The auto-merge of origin/main kept this branch's pre-#14171 anchor rows
beside the row-21 edit, flunking the census 26 ways (paired stale
anchors / unanchored sites in engine.ts and share-link-service.ts —
pure adjacent-row line rot, zero population change). Resolution per the
tool, not by hand: took main's page wholesale, then re-derived with
check-system-context-census --fix, which rewrote exactly ONE anchor
(row 21, protocol.ts 1737 to 1741 — this branch's publisher-block
shift). Delta vs origin/main is that single line; bare gate green: 109
sites anchored, 145 anchors resolve.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sharing: turning publicSharing off leaves already-minted links serving — and silently drops the object's redactFields

2 participants

@os-steve@claude