Hold publicSharing.eligibility at redemption, not only at mint - #13857

Merged
os-steve merged 4 commits into
mainfrom
claude/issue-13608-eligibility-at-redemption
Sep 1, 2026
Merged

Hold publicSharing.eligibility at redemption, not only at mint#13857
os-steve merged 4 commits into
mainfrom
claude/issue-13608-eligibility-at-redemption

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes#13608

publicSharing.eligibility was enforced at mint (createLinkassertEligible) and never again. resolveToken() checked revoked_at, expires_at, the audience gates, the password and record EXISTENCE — then served whatever survived, under SYSTEM_CTX, to a caller with no principal. Publish an article published + public, mint a link, flip audience to internal or status back to draft, and the old token kept resolving and kept serving the record in full. Declared ≠ enforced.

Triage's strongest argument is carried into the code comment: in that same door, recordStillExists is deliberately fail-CLOSED, so a deleted record stopped being served immediately while a reclassified one did not — two failure directions in one door, and the governed side wins.

Premise re-verified against packages/plugins/plugin-sharing/src/ on the base (the card measured the installed dist/index.js): located by symbol — createLink and assertEligible at the mint seam, resolveToken / getPolicy / recordStillExists at the serving seam. The card's reading holds on source, verbatim.

The four ruled items (maintainer ruling 2026-08-31, comment 5478574829)

1. Re-evaluate in resolveToken() before serving, fail-closed. The gate calls assertEligible itself — the same function the mint path calls — so mint and redemption cannot drift on strictness (true, never merely truthy), on the declared-field binding, or on which faults refuse. It is ONE read either way: when a predicate is declared, the existence probe's projection widens from ['id'] to the whole row instead of a second query being issued (loadRecordForServing(object, recordId, withRecord)), so an object with no eligibility key keeps the exact probe it always had — pinned in both directions. A policy tightening cutting off already-minted links is recorded as a behaviour change in the changeset and in migration-facing wording (item 3).

2. The refusal reuses the existing branch — measured at both seams.stillEligible catches assertEligible's throw and returns false; resolveToken answers null, the same value revoked, expired and record-gone already return. No new error code (the ADR-0112 ledger is untouched), no new response branch, no usage stamp, and nothing thrown that a route could turn into a 422. See the measurement section below for what an anonymous caller actually observes.

3. The documentation half ships here. Stated beside the declaration key (packages/spec/src/data/object.zod.ts — the eligibility TSDoc and its .describe(), which projects into content/docs/references/data/object.mdx), on the contract (IShareLinkService.resolveToken plus design note 6), and in the hand-written sharing security page with an upgrade callout (content/docs/protocol/objectql/security.mdx).

4. Option B is not here, and neither is the scope ban's subject. No write-time revocation, no audit trail, no hook mechanism; the diff adds no hook surface and touches nothing about hooks on platform objects an app declares a dependency on. The whole diff is 7 files: the service, its test, two spec docs sites, two docs pages, one changeset.

The refusal-shape measurement — and one honest reading

At the service seam, five arms were driven in one live backend and compared by IDENTITY, not by shape: reclassified · revoked · expired · record-gone · unknown token. All five return the same value, null. Nothing on the wire distinguishes them.

At the HTTP seam the real route was mounted on the real service and driven anonymously (the route's secure default context):

casestatuscode
reclassified after mint404INVALID_OR_EXPIRED
token that never existed404INVALID_OR_EXPIRED
revoked link410EXPIRED_OR_REVOKED

The reclassified answer is asserted deep-equal to the unknown-token answer, and asserted not to contain the words eligib or audience.

⚠️ Read this precisely, because it is not word-for-word "the same response as a revoked link": the reclassified link lands in the route's generic invalid / expired / revoked bucket, byte-identical to a token that never existed, and NOT in the 410 branch. That is forced by ruling item 2 itself — the route picks 410 off revoked_at / expires_at on the row, so reaching it would require the service to hand the route a distinguishable "ineligible" answer, which is exactly what item 2 forbids. And it is the strictly LESS informative of the two buckets: 410 would confirm to a holder that the token was real. The 410-vs-404 split between revoked and unknown is pre-existing, untouched here, and recorded in the test as measured rather than assumed.

Verification

Everything below was run on 926c1af6d (branch head), in a dedicated worktree, heavy runs serialized through scripts/pm/os-verify-lock.sh.

  • pnpm --filter @objectstack/plugin-sharing test30 files, 699 tests passed.
  • pnpm --filter @objectstack/plugin-sharing typecheck and pnpm --filter @objectstack/spec exec tsc --noEmit — clean.
    ⚠️ The package's tsconfig.jsonexcludes **/*.test.ts, so that typecheck says nothing about the new test code. Measured separately by reproducing the TEST_DEBT program (package tsconfig minus the test exclusion, dependency closure built): exactly 3 errors, the same TS6133 ×2 + TS18048 ×1 the ledger freezes, zero of them in the edited file — the ratchet does not move.
  • pnpm lint (eslint . --no-inline-config, whole repo) — clean.
  • Gate family re-derived from the ACTUAL diff (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack), both sections read. All ran green, including: check:authorable-surface, check:generated (14/14 artifacts current), check:docs, check:doc-authoring, check:yaml-examples, check:llms-txt, check:skill-refs, check:skill-examples, check:variant-docs, check:liveness, check:empty-state, check:strictness-ledger, check:i18n (9 packages in sync), check:i18n-stale-fill, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:cross-package-test-inputs, check:test-source-alias, check:type-check-coverage, check:system-context-census, check:nul-bytes, check:empty-changeset, check-adr-0087-registration, check-changeset-no-major, the doc gate family and the repo-root node gates.
  • Two gates needed their build prerequisite cleared first, and were then measured green rather than left as NOT MEASURED: check:i18n (the CLI + extract closure) and check:skill-examples (@objectstack/client-react declarations).
  • node scripts/check-test-completeness.mjs exits 3 = PREREQUISITE NOT MET locally (it parses a saved turbo run test log, which a local family run has none of) — recorded as NOT MEASURED, not as a pass.
  • Two generated artifacts moved with the source and are committed: content/docs/references/data/object.mdx (gen:schema then gen:docs) and the five re-anchored citations in content/docs/permissions/system-context.mdx (check-system-context-census --fix, pure line rot from this diff's line shifts).

Ablation

Predicted before the run: the eleven redemption pins redden; every mint-side pin and every "nothing else moved" control stays green.

The repair was committed first; the mutation was made in the working tree only, with a trap … EXIT INT TERM restoring by absolute path, and it was proved on disk before measuring — not by an editor's exit code:

[pre] HEAD blob = 3909d07fea4fd41585bf158319eaa97d76415b1a (working tree matches)
[mutate] two anchored replacements applied (0 hits would have aborted)
[post] mutated blob = 2e223b13668ed8aa6391f046ba1fada6580ccf1f
[markers] removed-call-sites=0 (want 0) · injected-markers=2 (want 2) · widening-sites=0 (want 0)
[vitest] exit=1
Test Files 1 failed (1)
Tests 11 failed | 22 passed (33)

The eleven that reddened:

× `audience` flipped to internal: the same token resolves, then does not
× `status` reverted to draft: the same token resolves, then does not
× at the service seam it is `null` — the identical value, not a lookalike
× at the HTTP seam an anonymous caller cannot tell it from an unknown token
× the ineligible arm returns — it never throws a code an anonymous caller could read
× a refused redemption stamps no usage — exactly like a revoked one
× a predicate that no longer compiles refuses a record that still qualifies
× a predicate naming an UNDECLARED key refuses as a fault, not as a verdict
× a predicate resolving to a non-boolean has not consented
× the refusal reason names the two things an operator needs, and stays server-side
× the eligibility read WIDENS the same probe rather than adding a second query

Restore proved by state, not by the trap firing: git status clean, git diff HEAD empty, git hash-object back to the HEAD blob 3909d07f…, zero ablation markers left; the file re-ran 33/33 green afterwards.

⚠️ The first ablation run reddened only TEN, and that is the run worth reporting: "at the service seam it is null" passed with the gate fully removed, because it deleted the reclassified record to exercise the #5190 arm — so the existence probe answered null for the reclassified token too, and the assertion held for a reason that has nothing to do with eligibility. A green-in-both-directions case is a declared control, never ablation evidence. It was repaired (the record-gone arm now has its own record; the reclassified one stays in the table) and the pin then reddened. The gate's own arms are still measured as controls: the two #5190 deleted-record cases, the still-eligible resolve with its redactFields set unchanged, the no-predicate object, and the id-only projection all stay green under ablation.

⛔ Clause ② — declared, not self-cleared

This diff hits the clause-② path limb: packages/spec/src/contracts/share-link-service.ts and packages/spec/src/data/object.zod.ts (both under packages/spec/src/**, named by the re-derivation as the suspect surface). No accept-set moves and no key is added, renamed or removed — the spec change is documentation of semantics the runtime now holds — but that judgement is not mine to make. Handed back to the PM: this may not enqueue until the contract review clears it.

Out of scope, filed

Generated by Claude Code


Generated by Claude Code

…demption
`ShareLinkService.resolveToken()` now re-evaluates the object's declared
`publicSharing.eligibility` predicate against the record it is about to
serve, through the same `assertEligible` the mint path calls. A record
reclassified out of the policy after a link was minted stops being served
through that link; an unevaluable predicate refuses (fail-closed), matching
mint.
The refusal reuses the undifferentiated `null` a revoked, expired or unknown
token already gets — no new error code, no new response branch, no usage
stamp — because distinguishing those cases for a caller with no principal is
an existence oracle. The readable reason goes to the server-side log.
Docs: the semantics are now stated beside the `publicSharing.eligibility`
declaration key, on `IShareLinkService.resolveToken`, and in the hand-written
sharing security page with an upgrade note.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…string
`check:doc-authoring` refuses issue-id citations in customer-facing strings
(maintainer ruling 2026-08-12) — `.describe()` prose projects into
content/docs/references/** and the generated skill artifacts, where `#NNNN`
resolves to nothing. The sentence is rephrased rather than truncated; the
reference stays in the adjacent TSDoc, which only an internal reader sees.
Also re-anchors the five system-context census citations this branch's line
shifts moved (`check:system-context-census --fix`).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
The first ablation run measured this case GREEN with the redemption gate
removed: it deleted the reclassified record to exercise the #5190 arm, so the
record-existence probe answered `null` for the reclassified token too and the
assertion held for a reason that has nothing to do with eligibility. The
record-gone arm now gets its own record, and the reclassified one stays in the
table — ineligible, not gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-sharing, @objectstack/spec, touching 7 documentable anchor(s).

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

  • content/docs/protocol/objectql/security.mdx(via publicSharing (symbol, a field of const object ObjectSchemaBase))
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 — 127 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 f532630d0246e93b36f869579eb5fa4184d51141packageMentionDocs.

Which tree this was computed on

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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f532630d0246e93b36f869579eb5fa4184d51141 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-warrenClaude

Copy link
Copy Markdown
Collaborator

Contract review (Clause ②) — PASS

Reviewed at head 926c1af6d7bd2bb19195aeffc8354e4860ef8c77, still the head. Rendered by a CONTRACT_REVIEW_TIER reviewer in an isolated context; transcript tier-verified before adoption (36 harness-stamped assistant turns, 100% at tier, first and last included, service_tier: standard throughout, zero fallback evidence). The triage seat runs below tier and therefore adopts verbatim or voids whole — no rewriting, trimming or softening. Adopted verbatim, unedited:

VERDICT: PASS
CLAUSE-2-PATH: yes
CLAUSE-2-CONTENT: no
DECLARATION-HONEST: yes
ONE-LINE: PASS — the spec half of the diff moves no accept-set and widens no surface (its one normative addition transcribes maintainer ruling item 2 onto an already-published, unchanged SPI shape), so only the path limb fires and every factual clause in the PR's declaration checked out true against the actual diff; the needs:contract-review label may be cleared.
FINDINGS:
- Path limb fires on exactly two files: "packages/spec/src/contracts/share-link-service.ts" and "packages/spec/src/data/object.zod.ts", both under packages/spec/src/**, out of 8 changed files enumerated from the API.
- Zod accept-set is unmoved: the changed line still reads "eligibility: z.string().optional().describe(" on both sides; the diff around it is TSDoc and describe-string only, "redactFields: z.array(z.string()).optional()" and the closing "}).optional().describe('Public share-link policy" are untouched, so the strictObject accepted key set is byte-identical.
- Content limb argued NO, not asserted: IShareLinkService IS published — package.json exports has a "./contracts" subpath and the barrel carries "export * from './share-link-service.js';" — so the new "Implementations MUST NOT return a distinguishable answer per reason, and MUST NOT throw one either" is published normative text; but it adds no method, key, or accepted input (it NARROWS conforming implementations), and it transcribes ruling item 2 verbatim ("⛔ 不新设「不再合格」的可区分应答", comment 5478574829) — the behaviour it makes normative is exactly the runtime security change the 2026-08-28 negative boundary assigns to the maintainer floor, whose control (the ruling) has been exercised; ruling item 3 ordered this documentation to ship with the card ("C 的文档半边必做,随本卡交付"). Same reasoning covers the .describe() rewrite and design note 6: they document WHEN an unchanged-shape key is enforced; the projected "content/docs/references/data/object.mdx" row changes text, not shape.
- Declaration honest, with one nuance: "This diff hits the clause-② path limb" — true; "No accept-set moves and no key is added, renamed or removed" — true against the patch; "the spec change is documentation of semantics the runtime now holds" — true but understated, since the resolveToken TSDoc is normative MUST-NOT language binding all implementers, not mere description; the body's own "that judgement is not mine to make" hands exactly that call here, so it is not a false claim.
- Machine spelling ABSENT from the claim comment (systemic gap, recorded not fixed): comment 5479312665 says "档位:**条款② 按实际 diff 在入队闸门判**(裁决原文如此)" — neither "Clause-②: yes" nor "Clause-②: no" appears verbatim, though SKILL.md requires "恰这两种拼写:`Clause-②: yes` / `Clause-②: no`" in the claim comment and ensure-pm-labels.sh confirms the gate reads "the card's claim comment". Control: "条款②" does hit in that comment. Enqueue remains blocked by the path limb regardless, so this does not change this card's gating outcome.
- Body's file count is off by one: "The whole diff is 7 files" but the API enumerates 8 — the uncounted file is "content/docs/permissions/system-context.mdx", which the body itself discloses elsewhere as "the five re-anchored citations in content/docs/permissions/system-context.mdx" (pure line-anchor rot, ":413" → ":423" etc.). Inaccurate arithmetic, not scope smuggling.
- Changeset honest: the file itself grades "@objectstack/plugin-sharing": minor and "@objectstack/spec": minor with a "**BREAKING** runtime behaviour change" banner — file matches body (unlike the sibling PR's patch-vs-minor mismatch), and "minor" is the repo's ceiling: scripts/check-changeset-no-major.mjs states "During the launch window we ship breaking changes as `minor`" and fails any major.
- ADR-0087 claim holds: "<!-- adr-0087: not-required (no-migration-prescription)" is present and its factual basis — "publicSharing.eligibility keeps its name, its type and its accept-set" — is confirmed by the zod patch; nothing authorable is removed, renamed, or re-shaped.
- Scope verified: no write-time revocation, no audit-trail rows, no hook mechanism anywhere in the 8-file diff (the only new observability is the server-side "this.logger?.warn?.('[share-link] redemption refused" line, which ruling item 2 itself directs to the log); nothing under content/docs/releases/ is touched (control: the 8-file enumeration is non-empty and fully listed).
- Head fresh and green: the branch tip is still 926c1af6d7bd2bb19195aeffc8354e4860ef8c77 (the brief's SHA), and all 36 check runs at that head are completed — 33 success, 3 skipped, none failed, none in_progress.

Carrier action

needs:contract-review is being cleared from both carriers in this same stroke — this PR and card #13608. Provenance: maintainer ruling 2026-08-25 ("审核通过你应该直接负责合并吧,还要等原始的项目经理吗"), under which a PASS clears the gate label rather than parking it for a second opinion.

⚠️The landing action is NOT mine and I am not taking it. Contract review belongs to the card's dispatching seat (domain:services here); the triage seat's standing round is explicitly "非放行必要条件,在线时作事后审计/抽查" and "⛔ 不与席内复核抢同一载体的放行动作". So this is an audit PASS: the gate is cleared, and the pre-landing checks → flip ready → enqueue sequence in landing-operations.md is the dispatching seat's to run. ⛔ Not flipping ready, not arming auto-merge, not approving — 席位永不代批 is unchanged.

Two record defects worth fixing while you are here — neither blocks

  1. The body says "The whole diff is 7 files"; it is 8. The uncounted one is content/docs/permissions/system-context.mdx, which the body itself discloses two sections later. Arithmetic, not smuggling — but the sentence is used as a scope guarantee, so it should be right.
  2. Card sharing: publicSharing.eligibility is evaluated only at mint — a link keeps serving a record after it stops being eligible #13608's claim comment carries no machine Clause-②: line. It reads "档位:条款② 按实际 diff 在入队闸门判(裁决原文如此)". The gate's declaration limb reads that comment and fixes exactly two spellings. Here the path limb fired and held the PR regardless, so nothing was lost — but on a PR touching no contract path, that comment is the only limb that can fire. Systemic; filed as The Clause-②: yes | no machine spelling is missing from the claim comment on 2 of 3 measured cards — the enqueue gate's predicate reads it there, and it is not there #13914.

Generated by Claude Code

@os-steveClaude

Copy link
Copy Markdown
Collaborator

契约复审:PASS —— 逐字采纳,标签已清,同笔落地

你在 PR 正文里把条款②路径肢声明而不自清(packages/spec/src/contracts/share-link-service.ts + packages/spec/src/data/object.zod.ts),交回 PM —— 这是对的做法。复审已执行完毕。

复审席资格,机读在案(⛔ 不是自述):

  • 降档保险丝:本席自会话 get_session 读回 last_served_modelCONTRACT_REVIEW_TIER自会话不得复审,改派达档子代理。
  • 转录核验(采信前必做):子代理 transcript 中 harness 逐消息盖章的 model 字段分布 ——
assistant 轮次: 89
盖章 "claude-fable-5": 89
非 fable 盖章(回退证据): 0

⇒ 产出裁决的每一轮都在档,零回退 ⇒ 裁决合法,按「逐字采纳或整体作废」二选一中的逐字采纳。下面是复审席的原文,⛔ 未改写、未删节、未润色:

CONTRACT REVIEW — PR #13857 @ 926c1af6d
VERDICT: PASS
INCREMENT REVIEWED: packages/spec/src/contracts/share-link-service.ts, packages/spec/src/data/object.zod.ts (diffed 16c3601d2 [merge-base with origin/main] → 926c1af6d)
FINDINGS:
none (one scoped observation, not a defect, recorded in BASIS item 4)
BASIS:
1. Schema shape — UNCHANGED. Comment-stripped code diff of both files between merge-base and head:
contracts/share-link-service.ts is byte-identical (JSDoc-only change); object.zod.ts differs by
exactly one string literal — the `.describe()` on `publicSharing.eligibility`. The zod chain is
`z.string().optional()` on both sides; no key added/removed/renamed, no optionality flipped, no
accept-set moved. Machine-confirmed: `check:spec-changes` green at head with `spec-changes.json`
untouched by the PR (0 baseline files in the diff).
2. Published surface — NOT WIDENED. Built `packages/spec` dist at head and ran `check:api-surface`:
"public API surface + factory signatures unchanged ✓". No new exports; `IShareLinkService`,
`resolveToken` signature and `ResolveShareLinkResult` are byte-identical in code.
3. Prose vs runtime — TRUE, verified against packages/plugins/plugin-sharing/src/share-link-service.ts
at head and executable-confirmed by its test suite (share-link-eligibility.test.ts: 33/33 pass):
- mint: `assertEligible` called in `createLink` before the insert → 422, no link row (pinned:
"a DRAFT record is refused with 422 and mints nothing");
- redemption: every `resolveToken` re-evaluates via `stillEligible`, which calls the SAME
`assertEligible` — one implementation at both seams, as the header note claims (pinned:
reclassified record "resolves, then does not");
- fail-closed at both points: compile failure, eval fault, and non-strict-`true` verdict all
refuse (three ELIGIBILITY_UNEVALUABLE / RECORD_NOT_ELIGIBLE arms in `assertEligible`; pinned
at both seams);
- undifferentiated `null`: identical value to revoked/expired/unknown, never a thrown readable
code, HTTP-seam indistinguishable, reason goes to `logger.warn` only (all four pinned);
- "use_count/last_used_at on SUCCESSFUL resolution only": stamp sits after every gate,
immediately before the successful return (pinned: "a refused redemption stamps no usage");
- null-on: missing/short token, revoked, expired, audience, password, record-gone (#5190),
ineligible (#13608) — every listed arm present in `resolveToken`, each `return null`;
- "Evaluator is the same one used by sharing rules": both consume the canonical CEL front end
in @objectstack/formula (`parseCelToAstWithReason`, convergence #6132; sharing rules lower
the same parsed AST via `compileCelToFilter`);
- prose narrowing "CEL/JSONLogic" → "CEL" matches the runtime (dialect hardcoded `'cel'`).
4. Commitments — nothing promised beyond what ships; the fail-closed and both-points claims are
implemented and pinned. Scoped observation (not a defect): enforcement is governed by the parent
`publicSharing.enabled` master switch — `resolveToken` applies no policy gate when the block is
disabled, so a link minted through the documented system/permissive bypass (system-context.mdx
ledger row 37, refreshed in this PR), or minted while enabled and later orphaned by the author
disabling the whole block, redeems without the eligibility re-check. This is pre-existing
resolveToken structure, uniform across all sibling policy keys (`redactFields` likewise), not
introduced or altered by this increment, and the key's describe — like its siblings' — speaks
within an in-force policy.
5. Generated artifacts — PROPAGATED AND CONSISTENT. Full `check:generated` aggregate run at head in
a scratch worktree (after `gen:schema` + package build, both environment prerequisites, not
staleness): all 14 artifacts up to date, including `check:docs` ("230 generated files in sync",
exit 0). Repo-wide grep at head finds the eligibility describe in exactly two committed files:
the zod source and the regenerated `content/docs/references/data/object.mdx`, whose table row
matches the new `.describe()` verbatim. The build-time json-schema output (gitignored) emits the
new description verbatim at `properties.publicSharing.properties.eligibility.description`;
`json-schema.manifest/` is a name-set ratchet carrying no description text, correctly unmoved.
No generated page consumes the contracts JSDoc (it ships via built .d.ts hover text and the npm
tarball source only).

PM 侧收口

  • needs:contract-review 已清(PASS 才清标;FAIL 是 label-flip 交接,标不清)。
  • 落地前检,按最终文件清单当场重跑、⛔ 非回忆:node scripts/pm/check-governed-merges.mjs --test × 8 条路径 ⇒ 0 of 8 path(s) hit the register非受管面,走普通队列⚠️ 这条必须当场跑:packages/spec/src/** 在面上时最容易凭印象判成受管。
  • 全 check 绿(42 个 check run,无 failure),mergeable_state: clean,head 926c1af6d 与复审席核的是同一个 sha
  • ⇒ 依「清标即落地」(维护者 2026-08-25),转 ready + 挂 auto-merge,与清标同一笔。

BASIS 4 的那条 scoped observation 不随本 PR 处置,但它不是没人管:publicSharing.enabled 关掉后 resolveToken 不再有任何策略门这件事,正是你自己在「Out of scope, filed」里立的 #13856(p1,security,domain:services 队列里)。复审席独立地从契约面又撞到同一处 ⇒ 两条独立证据指向同一张卡,该卡的定级站得住。


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 00:02
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit fc9ba76Sep 1, 2026
47 checks passed
@os-steve
os-steve deleted the claude/issue-13608-eligibility-at-redemption branch September 1, 2026 00:38
zhuangjianguo pushed a commit that referenced this pull request Sep 1, 2026
The merge of origin/main routed content/docs/permissions/system-context.mdx
through the os-regen driver, which exits 0 without text-merging and leaves
git's pre-filled OURS side in place. That silently dropped the 16 anchor
re-points main had landed (#13829, #13934, #13910, #13857) while keeping this
branch's single re-point.
This commit takes main's side of the page and re-derives every anchor from the
merged tree with `pnpm gen:system-context-census`, which re-pointed row 21's
metadata-protocol/src/protocol.ts anchor to 1736. Prose is byte-identical on
both sides once line numbers are normalised, so nothing but line numbers moved.
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 1, 2026
…, so `rollbackToPackageCommit` stops planning off the weekday name (objectstack-ai#14036)
* fix(metadata-protocol): order the ADR-0067 commit timeline by instant, not by the weekday name
`created_at` is an engine-injected audit column: not in `datetimeFields`, and
`SqlDriver#formatOutput` repairs it only inside `if (this.isSqlite)`. The live
SQL dialects therefore hand it out of the record read door as a JS `Date` while
the SQLite family hands out canonical ISO-Z text.
Both ADR-0067 commit-timeline consumers compared `String(created_at)`, and
`String(aDate)` is `"Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)"` —
the LEADING token is the weekday NAME, so lexicographic order over those strings
is `Fri < Mon < Sat < Sun < Thu < Tue < Wed`. Unrelated to chronology, and
stable across the whole set, so it is wrong on every run and wrong the same way.
- `listCommits` returned the timeline in weekday-name order while claiming
newest-first; its own comment stated the assumption ("sort by the ISO
timestamp") and it was false on the production default driver.
- `rollbackToPackageCommit` both consumed that ordering and re-derived the same
comparison itself, so neither site could correct the other: it reverted
`apply` commits OLDER than the target and skipped the newer ones it exists to
undo.
Both sites now compare canonical absolute instants through `compareAuditInstants`,
a sibling of the `canonicalVersionInstant` helper objectstack-ai#13382 landed one seam over in
this same file. The canonicalisation is reused; the ordering is new, because
`versionTokensAgree` answers equality between client-supplied version tokens and
an ordering question needs `<`/`>`. When either side does not denote an instant
the two are compared verbatim exactly as before, so only instant-bearing pairs
change verdict.
The pin drives a hand-made `Date` — `@objectstack/metadata-protocol` has no
driver dependency and must not grow one — over four consecutive days, the
smallest fixture for which no timezone alignment can make the old weekday
comparison agree with chronology.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
* chore(gates): re-point the isSystem census anchor and register the new engine double
Both are the gates' own sanctioned repairs for the line/ledger movement the fix
caused, applied with their own tooling and inspected:
- `check-system-context-census --fix` RE-POINTED row 21's anchor
`metadata-protocol/src/protocol.ts:1664` -> `:1736`, the 72-line shift the new
`compareAuditInstants` helper block introduced above it. No row was deleted and
no needle changed; the gate then reports 109 elevation read sites, 145 anchors
resolving.
- `check-engine-double-contract --write` ADDED one row recording that the new pin
file pins 1 `findOne` double ("1 added or grown, 0 lost"). The shrink-only
baseline is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
* chore(docs): re-derive the isSystem census after merging origin/main
The merge of origin/main routed content/docs/permissions/system-context.mdx
through the os-regen driver, which exits 0 without text-merging and leaves
git's pre-filled OURS side in place. That silently dropped the 16 anchor
re-points main had landed (objectstack-ai#13829, objectstack-ai#13934, objectstack-ai#13910, objectstack-ai#13857) while keeping this
branch's single re-point.
This commit takes main's side of the page and re-derives every anchor from the
merged tree with `pnpm gen:system-context-census`, which re-pointed row 21's
metadata-protocol/src/protocol.ts anchor to 1736. Prose is byte-identical on
both sides once line numbers are normalised, so nothing but line numbers moved.
---------
Co-authored-by: Claude <noreply@anthropic.com>
os-sales pushed a commit that referenced this pull request Sep 2, 2026
…ly at mint
`ShareLinkService.resolveToken()` now reads the object's CURRENT
`publicSharing` block on every redemption and refuses when `enabled` is
not `true` — before the record probe, before the usage stamp, before any
sibling key inside the block is evaluated. Re-enabling the block restores
the same tokens (a standing policy, not a revocation). How a link was
minted buys it nothing at redemption: rows minted under a system context
or the `permissive` bypass on a switched-off object refuse the same way,
and an object with no block at all is the same switch at its default.
The refusal reuses the undifferentiated `null` a revoked / expired /
unknown / ineligible token already gets — no new error code, no new
response branch, no usage stamp (over HTTP the generic 404). The readable
reason goes to the server-side log at `warn`, where the eligibility
refusal already writes its own.
Tests: the standing-policy pins (fake engine and real driver), the
HTTP-seam shape, the log line, the bypass-minted path, and the reversal
register for the #13856 pins that asserted serving on a disabled block.
Changeset marked as a breaking runtime change in PR #13857's form.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sharing: publicSharing.eligibility is evaluated only at mint — a link keeps serving a record after it stops being eligible

3 participants

@os-warren@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

Hold publicSharing.eligibility at redemption, not only at mint - #13857

Merged
os-steve merged 4 commits into
mainfrom
claude/issue-13608-eligibility-at-redemption
Sep 1, 2026
Merged

Hold publicSharing.eligibility at redemption, not only at mint#13857
os-steve merged 4 commits into
mainfrom
claude/issue-13608-eligibility-at-redemption

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes#13608

publicSharing.eligibility was enforced at mint (createLinkassertEligible) and never again. resolveToken() checked revoked_at, expires_at, the audience gates, the password and record EXISTENCE — then served whatever survived, under SYSTEM_CTX, to a caller with no principal. Publish an article published + public, mint a link, flip audience to internal or status back to draft, and the old token kept resolving and kept serving the record in full. Declared ≠ enforced.

Triage's strongest argument is carried into the code comment: in that same door, recordStillExists is deliberately fail-CLOSED, so a deleted record stopped being served immediately while a reclassified one did not — two failure directions in one door, and the governed side wins.

Premise re-verified against packages/plugins/plugin-sharing/src/ on the base (the card measured the installed dist/index.js): located by symbol — createLink and assertEligible at the mint seam, resolveToken / getPolicy / recordStillExists at the serving seam. The card's reading holds on source, verbatim.

The four ruled items (maintainer ruling 2026-08-31, comment 5478574829)

1. Re-evaluate in resolveToken() before serving, fail-closed. The gate calls assertEligible itself — the same function the mint path calls — so mint and redemption cannot drift on strictness (true, never merely truthy), on the declared-field binding, or on which faults refuse. It is ONE read either way: when a predicate is declared, the existence probe's projection widens from ['id'] to the whole row instead of a second query being issued (loadRecordForServing(object, recordId, withRecord)), so an object with no eligibility key keeps the exact probe it always had — pinned in both directions. A policy tightening cutting off already-minted links is recorded as a behaviour change in the changeset and in migration-facing wording (item 3).

2. The refusal reuses the existing branch — measured at both seams.stillEligible catches assertEligible's throw and returns false; resolveToken answers null, the same value revoked, expired and record-gone already return. No new error code (the ADR-0112 ledger is untouched), no new response branch, no usage stamp, and nothing thrown that a route could turn into a 422. See the measurement section below for what an anonymous caller actually observes.

3. The documentation half ships here. Stated beside the declaration key (packages/spec/src/data/object.zod.ts — the eligibility TSDoc and its .describe(), which projects into content/docs/references/data/object.mdx), on the contract (IShareLinkService.resolveToken plus design note 6), and in the hand-written sharing security page with an upgrade callout (content/docs/protocol/objectql/security.mdx).

4. Option B is not here, and neither is the scope ban's subject. No write-time revocation, no audit trail, no hook mechanism; the diff adds no hook surface and touches nothing about hooks on platform objects an app declares a dependency on. The whole diff is 7 files: the service, its test, two spec docs sites, two docs pages, one changeset.

The refusal-shape measurement — and one honest reading

At the service seam, five arms were driven in one live backend and compared by IDENTITY, not by shape: reclassified · revoked · expired · record-gone · unknown token. All five return the same value, null. Nothing on the wire distinguishes them.

At the HTTP seam the real route was mounted on the real service and driven anonymously (the route's secure default context):

casestatuscode
reclassified after mint404INVALID_OR_EXPIRED
token that never existed404INVALID_OR_EXPIRED
revoked link410EXPIRED_OR_REVOKED

The reclassified answer is asserted deep-equal to the unknown-token answer, and asserted not to contain the words eligib or audience.

⚠️ Read this precisely, because it is not word-for-word "the same response as a revoked link": the reclassified link lands in the route's generic invalid / expired / revoked bucket, byte-identical to a token that never existed, and NOT in the 410 branch. That is forced by ruling item 2 itself — the route picks 410 off revoked_at / expires_at on the row, so reaching it would require the service to hand the route a distinguishable "ineligible" answer, which is exactly what item 2 forbids. And it is the strictly LESS informative of the two buckets: 410 would confirm to a holder that the token was real. The 410-vs-404 split between revoked and unknown is pre-existing, untouched here, and recorded in the test as measured rather than assumed.

Verification

Everything below was run on 926c1af6d (branch head), in a dedicated worktree, heavy runs serialized through scripts/pm/os-verify-lock.sh.

  • pnpm --filter @objectstack/plugin-sharing test30 files, 699 tests passed.
  • pnpm --filter @objectstack/plugin-sharing typecheck and pnpm --filter @objectstack/spec exec tsc --noEmit — clean.
    ⚠️ The package's tsconfig.jsonexcludes **/*.test.ts, so that typecheck says nothing about the new test code. Measured separately by reproducing the TEST_DEBT program (package tsconfig minus the test exclusion, dependency closure built): exactly 3 errors, the same TS6133 ×2 + TS18048 ×1 the ledger freezes, zero of them in the edited file — the ratchet does not move.
  • pnpm lint (eslint . --no-inline-config, whole repo) — clean.
  • Gate family re-derived from the ACTUAL diff (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack), both sections read. All ran green, including: check:authorable-surface, check:generated (14/14 artifacts current), check:docs, check:doc-authoring, check:yaml-examples, check:llms-txt, check:skill-refs, check:skill-examples, check:variant-docs, check:liveness, check:empty-state, check:strictness-ledger, check:i18n (9 packages in sync), check:i18n-stale-fill, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:cross-package-test-inputs, check:test-source-alias, check:type-check-coverage, check:system-context-census, check:nul-bytes, check:empty-changeset, check-adr-0087-registration, check-changeset-no-major, the doc gate family and the repo-root node gates.
  • Two gates needed their build prerequisite cleared first, and were then measured green rather than left as NOT MEASURED: check:i18n (the CLI + extract closure) and check:skill-examples (@objectstack/client-react declarations).
  • node scripts/check-test-completeness.mjs exits 3 = PREREQUISITE NOT MET locally (it parses a saved turbo run test log, which a local family run has none of) — recorded as NOT MEASURED, not as a pass.
  • Two generated artifacts moved with the source and are committed: content/docs/references/data/object.mdx (gen:schema then gen:docs) and the five re-anchored citations in content/docs/permissions/system-context.mdx (check-system-context-census --fix, pure line rot from this diff's line shifts).

Ablation

Predicted before the run: the eleven redemption pins redden; every mint-side pin and every "nothing else moved" control stays green.

The repair was committed first; the mutation was made in the working tree only, with a trap … EXIT INT TERM restoring by absolute path, and it was proved on disk before measuring — not by an editor's exit code:

[pre] HEAD blob = 3909d07fea4fd41585bf158319eaa97d76415b1a (working tree matches)
[mutate] two anchored replacements applied (0 hits would have aborted)
[post] mutated blob = 2e223b13668ed8aa6391f046ba1fada6580ccf1f
[markers] removed-call-sites=0 (want 0) · injected-markers=2 (want 2) · widening-sites=0 (want 0)
[vitest] exit=1
Test Files 1 failed (1)
Tests 11 failed | 22 passed (33)

The eleven that reddened:

× `audience` flipped to internal: the same token resolves, then does not
× `status` reverted to draft: the same token resolves, then does not
× at the service seam it is `null` — the identical value, not a lookalike
× at the HTTP seam an anonymous caller cannot tell it from an unknown token
× the ineligible arm returns — it never throws a code an anonymous caller could read
× a refused redemption stamps no usage — exactly like a revoked one
× a predicate that no longer compiles refuses a record that still qualifies
× a predicate naming an UNDECLARED key refuses as a fault, not as a verdict
× a predicate resolving to a non-boolean has not consented
× the refusal reason names the two things an operator needs, and stays server-side
× the eligibility read WIDENS the same probe rather than adding a second query

Restore proved by state, not by the trap firing: git status clean, git diff HEAD empty, git hash-object back to the HEAD blob 3909d07f…, zero ablation markers left; the file re-ran 33/33 green afterwards.

⚠️ The first ablation run reddened only TEN, and that is the run worth reporting: "at the service seam it is null" passed with the gate fully removed, because it deleted the reclassified record to exercise the #5190 arm — so the existence probe answered null for the reclassified token too, and the assertion held for a reason that has nothing to do with eligibility. A green-in-both-directions case is a declared control, never ablation evidence. It was repaired (the record-gone arm now has its own record; the reclassified one stays in the table) and the pin then reddened. The gate's own arms are still measured as controls: the two #5190 deleted-record cases, the still-eligible resolve with its redactFields set unchanged, the no-predicate object, and the id-only projection all stay green under ablation.

⛔ Clause ② — declared, not self-cleared

This diff hits the clause-② path limb: packages/spec/src/contracts/share-link-service.ts and packages/spec/src/data/object.zod.ts (both under packages/spec/src/**, named by the re-derivation as the suspect surface). No accept-set moves and no key is added, renamed or removed — the spec change is documentation of semantics the runtime now holds — but that judgement is not mine to make. Handed back to the PM: this may not enqueue until the contract review clears it.

Out of scope, filed

Generated by Claude Code


Generated by Claude Code

…demption
`ShareLinkService.resolveToken()` now re-evaluates the object's declared
`publicSharing.eligibility` predicate against the record it is about to
serve, through the same `assertEligible` the mint path calls. A record
reclassified out of the policy after a link was minted stops being served
through that link; an unevaluable predicate refuses (fail-closed), matching
mint.
The refusal reuses the undifferentiated `null` a revoked, expired or unknown
token already gets — no new error code, no new response branch, no usage
stamp — because distinguishing those cases for a caller with no principal is
an existence oracle. The readable reason goes to the server-side log.
Docs: the semantics are now stated beside the `publicSharing.eligibility`
declaration key, on `IShareLinkService.resolveToken`, and in the hand-written
sharing security page with an upgrade note.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…string
`check:doc-authoring` refuses issue-id citations in customer-facing strings
(maintainer ruling 2026-08-12) — `.describe()` prose projects into
content/docs/references/** and the generated skill artifacts, where `#NNNN`
resolves to nothing. The sentence is rephrased rather than truncated; the
reference stays in the adjacent TSDoc, which only an internal reader sees.
Also re-anchors the five system-context census citations this branch's line
shifts moved (`check:system-context-census --fix`).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
The first ablation run measured this case GREEN with the redemption gate
removed: it deleted the reclassified record to exercise the #5190 arm, so the
record-existence probe answered `null` for the reclassified token too and the
assertion held for a reason that has nothing to do with eligibility. The
record-gone arm now gets its own record, and the reclassified one stays in the
table — ineligible, not gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-sharing, @objectstack/spec, touching 7 documentable anchor(s).

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

  • content/docs/protocol/objectql/security.mdx(via publicSharing (symbol, a field of const object ObjectSchemaBase))
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 — 127 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 f532630d0246e93b36f869579eb5fa4184d51141packageMentionDocs.

Which tree this was computed on

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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f532630d0246e93b36f869579eb5fa4184d51141 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-warrenClaude

Copy link
Copy Markdown
Collaborator

Contract review (Clause ②) — PASS

Reviewed at head 926c1af6d7bd2bb19195aeffc8354e4860ef8c77, still the head. Rendered by a CONTRACT_REVIEW_TIER reviewer in an isolated context; transcript tier-verified before adoption (36 harness-stamped assistant turns, 100% at tier, first and last included, service_tier: standard throughout, zero fallback evidence). The triage seat runs below tier and therefore adopts verbatim or voids whole — no rewriting, trimming or softening. Adopted verbatim, unedited:

VERDICT: PASS
CLAUSE-2-PATH: yes
CLAUSE-2-CONTENT: no
DECLARATION-HONEST: yes
ONE-LINE: PASS — the spec half of the diff moves no accept-set and widens no surface (its one normative addition transcribes maintainer ruling item 2 onto an already-published, unchanged SPI shape), so only the path limb fires and every factual clause in the PR's declaration checked out true against the actual diff; the needs:contract-review label may be cleared.
FINDINGS:
- Path limb fires on exactly two files: "packages/spec/src/contracts/share-link-service.ts" and "packages/spec/src/data/object.zod.ts", both under packages/spec/src/**, out of 8 changed files enumerated from the API.
- Zod accept-set is unmoved: the changed line still reads "eligibility: z.string().optional().describe(" on both sides; the diff around it is TSDoc and describe-string only, "redactFields: z.array(z.string()).optional()" and the closing "}).optional().describe('Public share-link policy" are untouched, so the strictObject accepted key set is byte-identical.
- Content limb argued NO, not asserted: IShareLinkService IS published — package.json exports has a "./contracts" subpath and the barrel carries "export * from './share-link-service.js';" — so the new "Implementations MUST NOT return a distinguishable answer per reason, and MUST NOT throw one either" is published normative text; but it adds no method, key, or accepted input (it NARROWS conforming implementations), and it transcribes ruling item 2 verbatim ("⛔ 不新设「不再合格」的可区分应答", comment 5478574829) — the behaviour it makes normative is exactly the runtime security change the 2026-08-28 negative boundary assigns to the maintainer floor, whose control (the ruling) has been exercised; ruling item 3 ordered this documentation to ship with the card ("C 的文档半边必做,随本卡交付"). Same reasoning covers the .describe() rewrite and design note 6: they document WHEN an unchanged-shape key is enforced; the projected "content/docs/references/data/object.mdx" row changes text, not shape.
- Declaration honest, with one nuance: "This diff hits the clause-② path limb" — true; "No accept-set moves and no key is added, renamed or removed" — true against the patch; "the spec change is documentation of semantics the runtime now holds" — true but understated, since the resolveToken TSDoc is normative MUST-NOT language binding all implementers, not mere description; the body's own "that judgement is not mine to make" hands exactly that call here, so it is not a false claim.
- Machine spelling ABSENT from the claim comment (systemic gap, recorded not fixed): comment 5479312665 says "档位:**条款② 按实际 diff 在入队闸门判**(裁决原文如此)" — neither "Clause-②: yes" nor "Clause-②: no" appears verbatim, though SKILL.md requires "恰这两种拼写:`Clause-②: yes` / `Clause-②: no`" in the claim comment and ensure-pm-labels.sh confirms the gate reads "the card's claim comment". Control: "条款②" does hit in that comment. Enqueue remains blocked by the path limb regardless, so this does not change this card's gating outcome.
- Body's file count is off by one: "The whole diff is 7 files" but the API enumerates 8 — the uncounted file is "content/docs/permissions/system-context.mdx", which the body itself discloses elsewhere as "the five re-anchored citations in content/docs/permissions/system-context.mdx" (pure line-anchor rot, ":413" → ":423" etc.). Inaccurate arithmetic, not scope smuggling.
- Changeset honest: the file itself grades "@objectstack/plugin-sharing": minor and "@objectstack/spec": minor with a "**BREAKING** runtime behaviour change" banner — file matches body (unlike the sibling PR's patch-vs-minor mismatch), and "minor" is the repo's ceiling: scripts/check-changeset-no-major.mjs states "During the launch window we ship breaking changes as `minor`" and fails any major.
- ADR-0087 claim holds: "<!-- adr-0087: not-required (no-migration-prescription)" is present and its factual basis — "publicSharing.eligibility keeps its name, its type and its accept-set" — is confirmed by the zod patch; nothing authorable is removed, renamed, or re-shaped.
- Scope verified: no write-time revocation, no audit-trail rows, no hook mechanism anywhere in the 8-file diff (the only new observability is the server-side "this.logger?.warn?.('[share-link] redemption refused" line, which ruling item 2 itself directs to the log); nothing under content/docs/releases/ is touched (control: the 8-file enumeration is non-empty and fully listed).
- Head fresh and green: the branch tip is still 926c1af6d7bd2bb19195aeffc8354e4860ef8c77 (the brief's SHA), and all 36 check runs at that head are completed — 33 success, 3 skipped, none failed, none in_progress.

Carrier action

needs:contract-review is being cleared from both carriers in this same stroke — this PR and card #13608. Provenance: maintainer ruling 2026-08-25 ("审核通过你应该直接负责合并吧,还要等原始的项目经理吗"), under which a PASS clears the gate label rather than parking it for a second opinion.

⚠️The landing action is NOT mine and I am not taking it. Contract review belongs to the card's dispatching seat (domain:services here); the triage seat's standing round is explicitly "非放行必要条件,在线时作事后审计/抽查" and "⛔ 不与席内复核抢同一载体的放行动作". So this is an audit PASS: the gate is cleared, and the pre-landing checks → flip ready → enqueue sequence in landing-operations.md is the dispatching seat's to run. ⛔ Not flipping ready, not arming auto-merge, not approving — 席位永不代批 is unchanged.

Two record defects worth fixing while you are here — neither blocks

  1. The body says "The whole diff is 7 files"; it is 8. The uncounted one is content/docs/permissions/system-context.mdx, which the body itself discloses two sections later. Arithmetic, not smuggling — but the sentence is used as a scope guarantee, so it should be right.
  2. Card sharing: publicSharing.eligibility is evaluated only at mint — a link keeps serving a record after it stops being eligible #13608's claim comment carries no machine Clause-②: line. It reads "档位:条款② 按实际 diff 在入队闸门判(裁决原文如此)". The gate's declaration limb reads that comment and fixes exactly two spellings. Here the path limb fired and held the PR regardless, so nothing was lost — but on a PR touching no contract path, that comment is the only limb that can fire. Systemic; filed as The Clause-②: yes | no machine spelling is missing from the claim comment on 2 of 3 measured cards — the enqueue gate's predicate reads it there, and it is not there #13914.

Generated by Claude Code

@os-steveClaude

Copy link
Copy Markdown
Collaborator

契约复审:PASS —— 逐字采纳,标签已清,同笔落地

你在 PR 正文里把条款②路径肢声明而不自清(packages/spec/src/contracts/share-link-service.ts + packages/spec/src/data/object.zod.ts),交回 PM —— 这是对的做法。复审已执行完毕。

复审席资格,机读在案(⛔ 不是自述):

  • 降档保险丝:本席自会话 get_session 读回 last_served_modelCONTRACT_REVIEW_TIER自会话不得复审,改派达档子代理。
  • 转录核验(采信前必做):子代理 transcript 中 harness 逐消息盖章的 model 字段分布 ——
assistant 轮次: 89
盖章 "claude-fable-5": 89
非 fable 盖章(回退证据): 0

⇒ 产出裁决的每一轮都在档,零回退 ⇒ 裁决合法,按「逐字采纳或整体作废」二选一中的逐字采纳。下面是复审席的原文,⛔ 未改写、未删节、未润色:

CONTRACT REVIEW — PR #13857 @ 926c1af6d
VERDICT: PASS
INCREMENT REVIEWED: packages/spec/src/contracts/share-link-service.ts, packages/spec/src/data/object.zod.ts (diffed 16c3601d2 [merge-base with origin/main] → 926c1af6d)
FINDINGS:
none (one scoped observation, not a defect, recorded in BASIS item 4)
BASIS:
1. Schema shape — UNCHANGED. Comment-stripped code diff of both files between merge-base and head:
contracts/share-link-service.ts is byte-identical (JSDoc-only change); object.zod.ts differs by
exactly one string literal — the `.describe()` on `publicSharing.eligibility`. The zod chain is
`z.string().optional()` on both sides; no key added/removed/renamed, no optionality flipped, no
accept-set moved. Machine-confirmed: `check:spec-changes` green at head with `spec-changes.json`
untouched by the PR (0 baseline files in the diff).
2. Published surface — NOT WIDENED. Built `packages/spec` dist at head and ran `check:api-surface`:
"public API surface + factory signatures unchanged ✓". No new exports; `IShareLinkService`,
`resolveToken` signature and `ResolveShareLinkResult` are byte-identical in code.
3. Prose vs runtime — TRUE, verified against packages/plugins/plugin-sharing/src/share-link-service.ts
at head and executable-confirmed by its test suite (share-link-eligibility.test.ts: 33/33 pass):
- mint: `assertEligible` called in `createLink` before the insert → 422, no link row (pinned:
"a DRAFT record is refused with 422 and mints nothing");
- redemption: every `resolveToken` re-evaluates via `stillEligible`, which calls the SAME
`assertEligible` — one implementation at both seams, as the header note claims (pinned:
reclassified record "resolves, then does not");
- fail-closed at both points: compile failure, eval fault, and non-strict-`true` verdict all
refuse (three ELIGIBILITY_UNEVALUABLE / RECORD_NOT_ELIGIBLE arms in `assertEligible`; pinned
at both seams);
- undifferentiated `null`: identical value to revoked/expired/unknown, never a thrown readable
code, HTTP-seam indistinguishable, reason goes to `logger.warn` only (all four pinned);
- "use_count/last_used_at on SUCCESSFUL resolution only": stamp sits after every gate,
immediately before the successful return (pinned: "a refused redemption stamps no usage");
- null-on: missing/short token, revoked, expired, audience, password, record-gone (#5190),
ineligible (#13608) — every listed arm present in `resolveToken`, each `return null`;
- "Evaluator is the same one used by sharing rules": both consume the canonical CEL front end
in @objectstack/formula (`parseCelToAstWithReason`, convergence #6132; sharing rules lower
the same parsed AST via `compileCelToFilter`);
- prose narrowing "CEL/JSONLogic" → "CEL" matches the runtime (dialect hardcoded `'cel'`).
4. Commitments — nothing promised beyond what ships; the fail-closed and both-points claims are
implemented and pinned. Scoped observation (not a defect): enforcement is governed by the parent
`publicSharing.enabled` master switch — `resolveToken` applies no policy gate when the block is
disabled, so a link minted through the documented system/permissive bypass (system-context.mdx
ledger row 37, refreshed in this PR), or minted while enabled and later orphaned by the author
disabling the whole block, redeems without the eligibility re-check. This is pre-existing
resolveToken structure, uniform across all sibling policy keys (`redactFields` likewise), not
introduced or altered by this increment, and the key's describe — like its siblings' — speaks
within an in-force policy.
5. Generated artifacts — PROPAGATED AND CONSISTENT. Full `check:generated` aggregate run at head in
a scratch worktree (after `gen:schema` + package build, both environment prerequisites, not
staleness): all 14 artifacts up to date, including `check:docs` ("230 generated files in sync",
exit 0). Repo-wide grep at head finds the eligibility describe in exactly two committed files:
the zod source and the regenerated `content/docs/references/data/object.mdx`, whose table row
matches the new `.describe()` verbatim. The build-time json-schema output (gitignored) emits the
new description verbatim at `properties.publicSharing.properties.eligibility.description`;
`json-schema.manifest/` is a name-set ratchet carrying no description text, correctly unmoved.
No generated page consumes the contracts JSDoc (it ships via built .d.ts hover text and the npm
tarball source only).

PM 侧收口

  • needs:contract-review 已清(PASS 才清标;FAIL 是 label-flip 交接,标不清)。
  • 落地前检,按最终文件清单当场重跑、⛔ 非回忆:node scripts/pm/check-governed-merges.mjs --test × 8 条路径 ⇒ 0 of 8 path(s) hit the register非受管面,走普通队列⚠️ 这条必须当场跑:packages/spec/src/** 在面上时最容易凭印象判成受管。
  • 全 check 绿(42 个 check run,无 failure),mergeable_state: clean,head 926c1af6d 与复审席核的是同一个 sha
  • ⇒ 依「清标即落地」(维护者 2026-08-25),转 ready + 挂 auto-merge,与清标同一笔。

BASIS 4 的那条 scoped observation 不随本 PR 处置,但它不是没人管:publicSharing.enabled 关掉后 resolveToken 不再有任何策略门这件事,正是你自己在「Out of scope, filed」里立的 #13856(p1,security,domain:services 队列里)。复审席独立地从契约面又撞到同一处 ⇒ 两条独立证据指向同一张卡,该卡的定级站得住。


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 00:02
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit fc9ba76Sep 1, 2026
47 checks passed
@os-steve
os-steve deleted the claude/issue-13608-eligibility-at-redemption branch September 1, 2026 00:38
zhuangjianguo pushed a commit that referenced this pull request Sep 1, 2026
The merge of origin/main routed content/docs/permissions/system-context.mdx
through the os-regen driver, which exits 0 without text-merging and leaves
git's pre-filled OURS side in place. That silently dropped the 16 anchor
re-points main had landed (#13829, #13934, #13910, #13857) while keeping this
branch's single re-point.
This commit takes main's side of the page and re-derives every anchor from the
merged tree with `pnpm gen:system-context-census`, which re-pointed row 21's
metadata-protocol/src/protocol.ts anchor to 1736. Prose is byte-identical on
both sides once line numbers are normalised, so nothing but line numbers moved.
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 1, 2026
…, so `rollbackToPackageCommit` stops planning off the weekday name (objectstack-ai#14036)
* fix(metadata-protocol): order the ADR-0067 commit timeline by instant, not by the weekday name
`created_at` is an engine-injected audit column: not in `datetimeFields`, and
`SqlDriver#formatOutput` repairs it only inside `if (this.isSqlite)`. The live
SQL dialects therefore hand it out of the record read door as a JS `Date` while
the SQLite family hands out canonical ISO-Z text.
Both ADR-0067 commit-timeline consumers compared `String(created_at)`, and
`String(aDate)` is `"Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)"` —
the LEADING token is the weekday NAME, so lexicographic order over those strings
is `Fri < Mon < Sat < Sun < Thu < Tue < Wed`. Unrelated to chronology, and
stable across the whole set, so it is wrong on every run and wrong the same way.
- `listCommits` returned the timeline in weekday-name order while claiming
newest-first; its own comment stated the assumption ("sort by the ISO
timestamp") and it was false on the production default driver.
- `rollbackToPackageCommit` both consumed that ordering and re-derived the same
comparison itself, so neither site could correct the other: it reverted
`apply` commits OLDER than the target and skipped the newer ones it exists to
undo.
Both sites now compare canonical absolute instants through `compareAuditInstants`,
a sibling of the `canonicalVersionInstant` helper objectstack-ai#13382 landed one seam over in
this same file. The canonicalisation is reused; the ordering is new, because
`versionTokensAgree` answers equality between client-supplied version tokens and
an ordering question needs `<`/`>`. When either side does not denote an instant
the two are compared verbatim exactly as before, so only instant-bearing pairs
change verdict.
The pin drives a hand-made `Date` — `@objectstack/metadata-protocol` has no
driver dependency and must not grow one — over four consecutive days, the
smallest fixture for which no timezone alignment can make the old weekday
comparison agree with chronology.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
* chore(gates): re-point the isSystem census anchor and register the new engine double
Both are the gates' own sanctioned repairs for the line/ledger movement the fix
caused, applied with their own tooling and inspected:
- `check-system-context-census --fix` RE-POINTED row 21's anchor
`metadata-protocol/src/protocol.ts:1664` -> `:1736`, the 72-line shift the new
`compareAuditInstants` helper block introduced above it. No row was deleted and
no needle changed; the gate then reports 109 elevation read sites, 145 anchors
resolving.
- `check-engine-double-contract --write` ADDED one row recording that the new pin
file pins 1 `findOne` double ("1 added or grown, 0 lost"). The shrink-only
baseline is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
* chore(docs): re-derive the isSystem census after merging origin/main
The merge of origin/main routed content/docs/permissions/system-context.mdx
through the os-regen driver, which exits 0 without text-merging and leaves
git's pre-filled OURS side in place. That silently dropped the 16 anchor
re-points main had landed (objectstack-ai#13829, objectstack-ai#13934, objectstack-ai#13910, objectstack-ai#13857) while keeping this
branch's single re-point.
This commit takes main's side of the page and re-derives every anchor from the
merged tree with `pnpm gen:system-context-census`, which re-pointed row 21's
metadata-protocol/src/protocol.ts anchor to 1736. Prose is byte-identical on
both sides once line numbers are normalised, so nothing but line numbers moved.
---------
Co-authored-by: Claude <noreply@anthropic.com>
os-sales pushed a commit that referenced this pull request Sep 2, 2026
…ly at mint
`ShareLinkService.resolveToken()` now reads the object's CURRENT
`publicSharing` block on every redemption and refuses when `enabled` is
not `true` — before the record probe, before the usage stamp, before any
sibling key inside the block is evaluated. Re-enabling the block restores
the same tokens (a standing policy, not a revocation). How a link was
minted buys it nothing at redemption: rows minted under a system context
or the `permissive` bypass on a switched-off object refuse the same way,
and an object with no block at all is the same switch at its default.
The refusal reuses the undifferentiated `null` a revoked / expired /
unknown / ineligible token already gets — no new error code, no new
response branch, no usage stamp (over HTTP the generic 404). The readable
reason goes to the server-side log at `warn`, where the eligibility
refusal already writes its own.
Tests: the standing-policy pins (fake engine and real driver), the
HTTP-seam shape, the log line, the bypass-minted path, and the reversal
register for the #13856 pins that asserted serving on a disabled block.
Changeset marked as a breaking runtime change in PR #13857's form.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sharing: publicSharing.eligibility is evaluated only at mint — a link keeps serving a record after it stops being eligible

3 participants

@os-warren@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

Hold publicSharing.eligibility at redemption, not only at mint - #13857

Merged
os-steve merged 4 commits into
mainfrom
claude/issue-13608-eligibility-at-redemption
Sep 1, 2026
Merged

Hold publicSharing.eligibility at redemption, not only at mint#13857
os-steve merged 4 commits into
mainfrom
claude/issue-13608-eligibility-at-redemption

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes#13608

publicSharing.eligibility was enforced at mint (createLinkassertEligible) and never again. resolveToken() checked revoked_at, expires_at, the audience gates, the password and record EXISTENCE — then served whatever survived, under SYSTEM_CTX, to a caller with no principal. Publish an article published + public, mint a link, flip audience to internal or status back to draft, and the old token kept resolving and kept serving the record in full. Declared ≠ enforced.

Triage's strongest argument is carried into the code comment: in that same door, recordStillExists is deliberately fail-CLOSED, so a deleted record stopped being served immediately while a reclassified one did not — two failure directions in one door, and the governed side wins.

Premise re-verified against packages/plugins/plugin-sharing/src/ on the base (the card measured the installed dist/index.js): located by symbol — createLink and assertEligible at the mint seam, resolveToken / getPolicy / recordStillExists at the serving seam. The card's reading holds on source, verbatim.

The four ruled items (maintainer ruling 2026-08-31, comment 5478574829)

1. Re-evaluate in resolveToken() before serving, fail-closed. The gate calls assertEligible itself — the same function the mint path calls — so mint and redemption cannot drift on strictness (true, never merely truthy), on the declared-field binding, or on which faults refuse. It is ONE read either way: when a predicate is declared, the existence probe's projection widens from ['id'] to the whole row instead of a second query being issued (loadRecordForServing(object, recordId, withRecord)), so an object with no eligibility key keeps the exact probe it always had — pinned in both directions. A policy tightening cutting off already-minted links is recorded as a behaviour change in the changeset and in migration-facing wording (item 3).

2. The refusal reuses the existing branch — measured at both seams.stillEligible catches assertEligible's throw and returns false; resolveToken answers null, the same value revoked, expired and record-gone already return. No new error code (the ADR-0112 ledger is untouched), no new response branch, no usage stamp, and nothing thrown that a route could turn into a 422. See the measurement section below for what an anonymous caller actually observes.

3. The documentation half ships here. Stated beside the declaration key (packages/spec/src/data/object.zod.ts — the eligibility TSDoc and its .describe(), which projects into content/docs/references/data/object.mdx), on the contract (IShareLinkService.resolveToken plus design note 6), and in the hand-written sharing security page with an upgrade callout (content/docs/protocol/objectql/security.mdx).

4. Option B is not here, and neither is the scope ban's subject. No write-time revocation, no audit trail, no hook mechanism; the diff adds no hook surface and touches nothing about hooks on platform objects an app declares a dependency on. The whole diff is 7 files: the service, its test, two spec docs sites, two docs pages, one changeset.

The refusal-shape measurement — and one honest reading

At the service seam, five arms were driven in one live backend and compared by IDENTITY, not by shape: reclassified · revoked · expired · record-gone · unknown token. All five return the same value, null. Nothing on the wire distinguishes them.

At the HTTP seam the real route was mounted on the real service and driven anonymously (the route's secure default context):

casestatuscode
reclassified after mint404INVALID_OR_EXPIRED
token that never existed404INVALID_OR_EXPIRED
revoked link410EXPIRED_OR_REVOKED

The reclassified answer is asserted deep-equal to the unknown-token answer, and asserted not to contain the words eligib or audience.

⚠️ Read this precisely, because it is not word-for-word "the same response as a revoked link": the reclassified link lands in the route's generic invalid / expired / revoked bucket, byte-identical to a token that never existed, and NOT in the 410 branch. That is forced by ruling item 2 itself — the route picks 410 off revoked_at / expires_at on the row, so reaching it would require the service to hand the route a distinguishable "ineligible" answer, which is exactly what item 2 forbids. And it is the strictly LESS informative of the two buckets: 410 would confirm to a holder that the token was real. The 410-vs-404 split between revoked and unknown is pre-existing, untouched here, and recorded in the test as measured rather than assumed.

Verification

Everything below was run on 926c1af6d (branch head), in a dedicated worktree, heavy runs serialized through scripts/pm/os-verify-lock.sh.

  • pnpm --filter @objectstack/plugin-sharing test30 files, 699 tests passed.
  • pnpm --filter @objectstack/plugin-sharing typecheck and pnpm --filter @objectstack/spec exec tsc --noEmit — clean.
    ⚠️ The package's tsconfig.jsonexcludes **/*.test.ts, so that typecheck says nothing about the new test code. Measured separately by reproducing the TEST_DEBT program (package tsconfig minus the test exclusion, dependency closure built): exactly 3 errors, the same TS6133 ×2 + TS18048 ×1 the ledger freezes, zero of them in the edited file — the ratchet does not move.
  • pnpm lint (eslint . --no-inline-config, whole repo) — clean.
  • Gate family re-derived from the ACTUAL diff (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack), both sections read. All ran green, including: check:authorable-surface, check:generated (14/14 artifacts current), check:docs, check:doc-authoring, check:yaml-examples, check:llms-txt, check:skill-refs, check:skill-examples, check:variant-docs, check:liveness, check:empty-state, check:strictness-ledger, check:i18n (9 packages in sync), check:i18n-stale-fill, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:cross-package-test-inputs, check:test-source-alias, check:type-check-coverage, check:system-context-census, check:nul-bytes, check:empty-changeset, check-adr-0087-registration, check-changeset-no-major, the doc gate family and the repo-root node gates.
  • Two gates needed their build prerequisite cleared first, and were then measured green rather than left as NOT MEASURED: check:i18n (the CLI + extract closure) and check:skill-examples (@objectstack/client-react declarations).
  • node scripts/check-test-completeness.mjs exits 3 = PREREQUISITE NOT MET locally (it parses a saved turbo run test log, which a local family run has none of) — recorded as NOT MEASURED, not as a pass.
  • Two generated artifacts moved with the source and are committed: content/docs/references/data/object.mdx (gen:schema then gen:docs) and the five re-anchored citations in content/docs/permissions/system-context.mdx (check-system-context-census --fix, pure line rot from this diff's line shifts).

Ablation

Predicted before the run: the eleven redemption pins redden; every mint-side pin and every "nothing else moved" control stays green.

The repair was committed first; the mutation was made in the working tree only, with a trap … EXIT INT TERM restoring by absolute path, and it was proved on disk before measuring — not by an editor's exit code:

[pre] HEAD blob = 3909d07fea4fd41585bf158319eaa97d76415b1a (working tree matches)
[mutate] two anchored replacements applied (0 hits would have aborted)
[post] mutated blob = 2e223b13668ed8aa6391f046ba1fada6580ccf1f
[markers] removed-call-sites=0 (want 0) · injected-markers=2 (want 2) · widening-sites=0 (want 0)
[vitest] exit=1
Test Files 1 failed (1)
Tests 11 failed | 22 passed (33)

The eleven that reddened:

× `audience` flipped to internal: the same token resolves, then does not
× `status` reverted to draft: the same token resolves, then does not
× at the service seam it is `null` — the identical value, not a lookalike
× at the HTTP seam an anonymous caller cannot tell it from an unknown token
× the ineligible arm returns — it never throws a code an anonymous caller could read
× a refused redemption stamps no usage — exactly like a revoked one
× a predicate that no longer compiles refuses a record that still qualifies
× a predicate naming an UNDECLARED key refuses as a fault, not as a verdict
× a predicate resolving to a non-boolean has not consented
× the refusal reason names the two things an operator needs, and stays server-side
× the eligibility read WIDENS the same probe rather than adding a second query

Restore proved by state, not by the trap firing: git status clean, git diff HEAD empty, git hash-object back to the HEAD blob 3909d07f…, zero ablation markers left; the file re-ran 33/33 green afterwards.

⚠️ The first ablation run reddened only TEN, and that is the run worth reporting: "at the service seam it is null" passed with the gate fully removed, because it deleted the reclassified record to exercise the #5190 arm — so the existence probe answered null for the reclassified token too, and the assertion held for a reason that has nothing to do with eligibility. A green-in-both-directions case is a declared control, never ablation evidence. It was repaired (the record-gone arm now has its own record; the reclassified one stays in the table) and the pin then reddened. The gate's own arms are still measured as controls: the two #5190 deleted-record cases, the still-eligible resolve with its redactFields set unchanged, the no-predicate object, and the id-only projection all stay green under ablation.

⛔ Clause ② — declared, not self-cleared

This diff hits the clause-② path limb: packages/spec/src/contracts/share-link-service.ts and packages/spec/src/data/object.zod.ts (both under packages/spec/src/**, named by the re-derivation as the suspect surface). No accept-set moves and no key is added, renamed or removed — the spec change is documentation of semantics the runtime now holds — but that judgement is not mine to make. Handed back to the PM: this may not enqueue until the contract review clears it.

Out of scope, filed

Generated by Claude Code


Generated by Claude Code

…demption
`ShareLinkService.resolveToken()` now re-evaluates the object's declared
`publicSharing.eligibility` predicate against the record it is about to
serve, through the same `assertEligible` the mint path calls. A record
reclassified out of the policy after a link was minted stops being served
through that link; an unevaluable predicate refuses (fail-closed), matching
mint.
The refusal reuses the undifferentiated `null` a revoked, expired or unknown
token already gets — no new error code, no new response branch, no usage
stamp — because distinguishing those cases for a caller with no principal is
an existence oracle. The readable reason goes to the server-side log.
Docs: the semantics are now stated beside the `publicSharing.eligibility`
declaration key, on `IShareLinkService.resolveToken`, and in the hand-written
sharing security page with an upgrade note.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…string
`check:doc-authoring` refuses issue-id citations in customer-facing strings
(maintainer ruling 2026-08-12) — `.describe()` prose projects into
content/docs/references/** and the generated skill artifacts, where `#NNNN`
resolves to nothing. The sentence is rephrased rather than truncated; the
reference stays in the adjacent TSDoc, which only an internal reader sees.
Also re-anchors the five system-context census citations this branch's line
shifts moved (`check:system-context-census --fix`).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
The first ablation run measured this case GREEN with the redemption gate
removed: it deleted the reclassified record to exercise the #5190 arm, so the
record-existence probe answered `null` for the reclassified token too and the
assertion held for a reason that has nothing to do with eligibility. The
record-gone arm now gets its own record, and the reclassified one stays in the
table — ineligible, not gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-sharing, @objectstack/spec, touching 7 documentable anchor(s).

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

  • content/docs/protocol/objectql/security.mdx(via publicSharing (symbol, a field of const object ObjectSchemaBase))
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 — 127 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 f532630d0246e93b36f869579eb5fa4184d51141packageMentionDocs.

Which tree this was computed on

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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f532630d0246e93b36f869579eb5fa4184d51141 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-warrenClaude

Copy link
Copy Markdown
Collaborator

Contract review (Clause ②) — PASS

Reviewed at head 926c1af6d7bd2bb19195aeffc8354e4860ef8c77, still the head. Rendered by a CONTRACT_REVIEW_TIER reviewer in an isolated context; transcript tier-verified before adoption (36 harness-stamped assistant turns, 100% at tier, first and last included, service_tier: standard throughout, zero fallback evidence). The triage seat runs below tier and therefore adopts verbatim or voids whole — no rewriting, trimming or softening. Adopted verbatim, unedited:

VERDICT: PASS
CLAUSE-2-PATH: yes
CLAUSE-2-CONTENT: no
DECLARATION-HONEST: yes
ONE-LINE: PASS — the spec half of the diff moves no accept-set and widens no surface (its one normative addition transcribes maintainer ruling item 2 onto an already-published, unchanged SPI shape), so only the path limb fires and every factual clause in the PR's declaration checked out true against the actual diff; the needs:contract-review label may be cleared.
FINDINGS:
- Path limb fires on exactly two files: "packages/spec/src/contracts/share-link-service.ts" and "packages/spec/src/data/object.zod.ts", both under packages/spec/src/**, out of 8 changed files enumerated from the API.
- Zod accept-set is unmoved: the changed line still reads "eligibility: z.string().optional().describe(" on both sides; the diff around it is TSDoc and describe-string only, "redactFields: z.array(z.string()).optional()" and the closing "}).optional().describe('Public share-link policy" are untouched, so the strictObject accepted key set is byte-identical.
- Content limb argued NO, not asserted: IShareLinkService IS published — package.json exports has a "./contracts" subpath and the barrel carries "export * from './share-link-service.js';" — so the new "Implementations MUST NOT return a distinguishable answer per reason, and MUST NOT throw one either" is published normative text; but it adds no method, key, or accepted input (it NARROWS conforming implementations), and it transcribes ruling item 2 verbatim ("⛔ 不新设「不再合格」的可区分应答", comment 5478574829) — the behaviour it makes normative is exactly the runtime security change the 2026-08-28 negative boundary assigns to the maintainer floor, whose control (the ruling) has been exercised; ruling item 3 ordered this documentation to ship with the card ("C 的文档半边必做,随本卡交付"). Same reasoning covers the .describe() rewrite and design note 6: they document WHEN an unchanged-shape key is enforced; the projected "content/docs/references/data/object.mdx" row changes text, not shape.
- Declaration honest, with one nuance: "This diff hits the clause-② path limb" — true; "No accept-set moves and no key is added, renamed or removed" — true against the patch; "the spec change is documentation of semantics the runtime now holds" — true but understated, since the resolveToken TSDoc is normative MUST-NOT language binding all implementers, not mere description; the body's own "that judgement is not mine to make" hands exactly that call here, so it is not a false claim.
- Machine spelling ABSENT from the claim comment (systemic gap, recorded not fixed): comment 5479312665 says "档位:**条款② 按实际 diff 在入队闸门判**(裁决原文如此)" — neither "Clause-②: yes" nor "Clause-②: no" appears verbatim, though SKILL.md requires "恰这两种拼写:`Clause-②: yes` / `Clause-②: no`" in the claim comment and ensure-pm-labels.sh confirms the gate reads "the card's claim comment". Control: "条款②" does hit in that comment. Enqueue remains blocked by the path limb regardless, so this does not change this card's gating outcome.
- Body's file count is off by one: "The whole diff is 7 files" but the API enumerates 8 — the uncounted file is "content/docs/permissions/system-context.mdx", which the body itself discloses elsewhere as "the five re-anchored citations in content/docs/permissions/system-context.mdx" (pure line-anchor rot, ":413" → ":423" etc.). Inaccurate arithmetic, not scope smuggling.
- Changeset honest: the file itself grades "@objectstack/plugin-sharing": minor and "@objectstack/spec": minor with a "**BREAKING** runtime behaviour change" banner — file matches body (unlike the sibling PR's patch-vs-minor mismatch), and "minor" is the repo's ceiling: scripts/check-changeset-no-major.mjs states "During the launch window we ship breaking changes as `minor`" and fails any major.
- ADR-0087 claim holds: "<!-- adr-0087: not-required (no-migration-prescription)" is present and its factual basis — "publicSharing.eligibility keeps its name, its type and its accept-set" — is confirmed by the zod patch; nothing authorable is removed, renamed, or re-shaped.
- Scope verified: no write-time revocation, no audit-trail rows, no hook mechanism anywhere in the 8-file diff (the only new observability is the server-side "this.logger?.warn?.('[share-link] redemption refused" line, which ruling item 2 itself directs to the log); nothing under content/docs/releases/ is touched (control: the 8-file enumeration is non-empty and fully listed).
- Head fresh and green: the branch tip is still 926c1af6d7bd2bb19195aeffc8354e4860ef8c77 (the brief's SHA), and all 36 check runs at that head are completed — 33 success, 3 skipped, none failed, none in_progress.

Carrier action

needs:contract-review is being cleared from both carriers in this same stroke — this PR and card #13608. Provenance: maintainer ruling 2026-08-25 ("审核通过你应该直接负责合并吧,还要等原始的项目经理吗"), under which a PASS clears the gate label rather than parking it for a second opinion.

⚠️The landing action is NOT mine and I am not taking it. Contract review belongs to the card's dispatching seat (domain:services here); the triage seat's standing round is explicitly "非放行必要条件,在线时作事后审计/抽查" and "⛔ 不与席内复核抢同一载体的放行动作". So this is an audit PASS: the gate is cleared, and the pre-landing checks → flip ready → enqueue sequence in landing-operations.md is the dispatching seat's to run. ⛔ Not flipping ready, not arming auto-merge, not approving — 席位永不代批 is unchanged.

Two record defects worth fixing while you are here — neither blocks

  1. The body says "The whole diff is 7 files"; it is 8. The uncounted one is content/docs/permissions/system-context.mdx, which the body itself discloses two sections later. Arithmetic, not smuggling — but the sentence is used as a scope guarantee, so it should be right.
  2. Card sharing: publicSharing.eligibility is evaluated only at mint — a link keeps serving a record after it stops being eligible #13608's claim comment carries no machine Clause-②: line. It reads "档位:条款② 按实际 diff 在入队闸门判(裁决原文如此)". The gate's declaration limb reads that comment and fixes exactly two spellings. Here the path limb fired and held the PR regardless, so nothing was lost — but on a PR touching no contract path, that comment is the only limb that can fire. Systemic; filed as The Clause-②: yes | no machine spelling is missing from the claim comment on 2 of 3 measured cards — the enqueue gate's predicate reads it there, and it is not there #13914.

Generated by Claude Code

@os-steveClaude

Copy link
Copy Markdown
Collaborator

契约复审:PASS —— 逐字采纳,标签已清,同笔落地

你在 PR 正文里把条款②路径肢声明而不自清(packages/spec/src/contracts/share-link-service.ts + packages/spec/src/data/object.zod.ts),交回 PM —— 这是对的做法。复审已执行完毕。

复审席资格,机读在案(⛔ 不是自述):

  • 降档保险丝:本席自会话 get_session 读回 last_served_modelCONTRACT_REVIEW_TIER自会话不得复审,改派达档子代理。
  • 转录核验(采信前必做):子代理 transcript 中 harness 逐消息盖章的 model 字段分布 ——
assistant 轮次: 89
盖章 "claude-fable-5": 89
非 fable 盖章(回退证据): 0

⇒ 产出裁决的每一轮都在档,零回退 ⇒ 裁决合法,按「逐字采纳或整体作废」二选一中的逐字采纳。下面是复审席的原文,⛔ 未改写、未删节、未润色:

CONTRACT REVIEW — PR #13857 @ 926c1af6d
VERDICT: PASS
INCREMENT REVIEWED: packages/spec/src/contracts/share-link-service.ts, packages/spec/src/data/object.zod.ts (diffed 16c3601d2 [merge-base with origin/main] → 926c1af6d)
FINDINGS:
none (one scoped observation, not a defect, recorded in BASIS item 4)
BASIS:
1. Schema shape — UNCHANGED. Comment-stripped code diff of both files between merge-base and head:
contracts/share-link-service.ts is byte-identical (JSDoc-only change); object.zod.ts differs by
exactly one string literal — the `.describe()` on `publicSharing.eligibility`. The zod chain is
`z.string().optional()` on both sides; no key added/removed/renamed, no optionality flipped, no
accept-set moved. Machine-confirmed: `check:spec-changes` green at head with `spec-changes.json`
untouched by the PR (0 baseline files in the diff).
2. Published surface — NOT WIDENED. Built `packages/spec` dist at head and ran `check:api-surface`:
"public API surface + factory signatures unchanged ✓". No new exports; `IShareLinkService`,
`resolveToken` signature and `ResolveShareLinkResult` are byte-identical in code.
3. Prose vs runtime — TRUE, verified against packages/plugins/plugin-sharing/src/share-link-service.ts
at head and executable-confirmed by its test suite (share-link-eligibility.test.ts: 33/33 pass):
- mint: `assertEligible` called in `createLink` before the insert → 422, no link row (pinned:
"a DRAFT record is refused with 422 and mints nothing");
- redemption: every `resolveToken` re-evaluates via `stillEligible`, which calls the SAME
`assertEligible` — one implementation at both seams, as the header note claims (pinned:
reclassified record "resolves, then does not");
- fail-closed at both points: compile failure, eval fault, and non-strict-`true` verdict all
refuse (three ELIGIBILITY_UNEVALUABLE / RECORD_NOT_ELIGIBLE arms in `assertEligible`; pinned
at both seams);
- undifferentiated `null`: identical value to revoked/expired/unknown, never a thrown readable
code, HTTP-seam indistinguishable, reason goes to `logger.warn` only (all four pinned);
- "use_count/last_used_at on SUCCESSFUL resolution only": stamp sits after every gate,
immediately before the successful return (pinned: "a refused redemption stamps no usage");
- null-on: missing/short token, revoked, expired, audience, password, record-gone (#5190),
ineligible (#13608) — every listed arm present in `resolveToken`, each `return null`;
- "Evaluator is the same one used by sharing rules": both consume the canonical CEL front end
in @objectstack/formula (`parseCelToAstWithReason`, convergence #6132; sharing rules lower
the same parsed AST via `compileCelToFilter`);
- prose narrowing "CEL/JSONLogic" → "CEL" matches the runtime (dialect hardcoded `'cel'`).
4. Commitments — nothing promised beyond what ships; the fail-closed and both-points claims are
implemented and pinned. Scoped observation (not a defect): enforcement is governed by the parent
`publicSharing.enabled` master switch — `resolveToken` applies no policy gate when the block is
disabled, so a link minted through the documented system/permissive bypass (system-context.mdx
ledger row 37, refreshed in this PR), or minted while enabled and later orphaned by the author
disabling the whole block, redeems without the eligibility re-check. This is pre-existing
resolveToken structure, uniform across all sibling policy keys (`redactFields` likewise), not
introduced or altered by this increment, and the key's describe — like its siblings' — speaks
within an in-force policy.
5. Generated artifacts — PROPAGATED AND CONSISTENT. Full `check:generated` aggregate run at head in
a scratch worktree (after `gen:schema` + package build, both environment prerequisites, not
staleness): all 14 artifacts up to date, including `check:docs` ("230 generated files in sync",
exit 0). Repo-wide grep at head finds the eligibility describe in exactly two committed files:
the zod source and the regenerated `content/docs/references/data/object.mdx`, whose table row
matches the new `.describe()` verbatim. The build-time json-schema output (gitignored) emits the
new description verbatim at `properties.publicSharing.properties.eligibility.description`;
`json-schema.manifest/` is a name-set ratchet carrying no description text, correctly unmoved.
No generated page consumes the contracts JSDoc (it ships via built .d.ts hover text and the npm
tarball source only).

PM 侧收口

  • needs:contract-review 已清(PASS 才清标;FAIL 是 label-flip 交接,标不清)。
  • 落地前检,按最终文件清单当场重跑、⛔ 非回忆:node scripts/pm/check-governed-merges.mjs --test × 8 条路径 ⇒ 0 of 8 path(s) hit the register非受管面,走普通队列⚠️ 这条必须当场跑:packages/spec/src/** 在面上时最容易凭印象判成受管。
  • 全 check 绿(42 个 check run,无 failure),mergeable_state: clean,head 926c1af6d 与复审席核的是同一个 sha
  • ⇒ 依「清标即落地」(维护者 2026-08-25),转 ready + 挂 auto-merge,与清标同一笔。

BASIS 4 的那条 scoped observation 不随本 PR 处置,但它不是没人管:publicSharing.enabled 关掉后 resolveToken 不再有任何策略门这件事,正是你自己在「Out of scope, filed」里立的 #13856(p1,security,domain:services 队列里)。复审席独立地从契约面又撞到同一处 ⇒ 两条独立证据指向同一张卡,该卡的定级站得住。


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 00:02
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit fc9ba76Sep 1, 2026
47 checks passed
@os-steve
os-steve deleted the claude/issue-13608-eligibility-at-redemption branch September 1, 2026 00:38
zhuangjianguo pushed a commit that referenced this pull request Sep 1, 2026
The merge of origin/main routed content/docs/permissions/system-context.mdx
through the os-regen driver, which exits 0 without text-merging and leaves
git's pre-filled OURS side in place. That silently dropped the 16 anchor
re-points main had landed (#13829, #13934, #13910, #13857) while keeping this
branch's single re-point.
This commit takes main's side of the page and re-derives every anchor from the
merged tree with `pnpm gen:system-context-census`, which re-pointed row 21's
metadata-protocol/src/protocol.ts anchor to 1736. Prose is byte-identical on
both sides once line numbers are normalised, so nothing but line numbers moved.
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 1, 2026
…, so `rollbackToPackageCommit` stops planning off the weekday name (objectstack-ai#14036)
* fix(metadata-protocol): order the ADR-0067 commit timeline by instant, not by the weekday name
`created_at` is an engine-injected audit column: not in `datetimeFields`, and
`SqlDriver#formatOutput` repairs it only inside `if (this.isSqlite)`. The live
SQL dialects therefore hand it out of the record read door as a JS `Date` while
the SQLite family hands out canonical ISO-Z text.
Both ADR-0067 commit-timeline consumers compared `String(created_at)`, and
`String(aDate)` is `"Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)"` —
the LEADING token is the weekday NAME, so lexicographic order over those strings
is `Fri < Mon < Sat < Sun < Thu < Tue < Wed`. Unrelated to chronology, and
stable across the whole set, so it is wrong on every run and wrong the same way.
- `listCommits` returned the timeline in weekday-name order while claiming
newest-first; its own comment stated the assumption ("sort by the ISO
timestamp") and it was false on the production default driver.
- `rollbackToPackageCommit` both consumed that ordering and re-derived the same
comparison itself, so neither site could correct the other: it reverted
`apply` commits OLDER than the target and skipped the newer ones it exists to
undo.
Both sites now compare canonical absolute instants through `compareAuditInstants`,
a sibling of the `canonicalVersionInstant` helper objectstack-ai#13382 landed one seam over in
this same file. The canonicalisation is reused; the ordering is new, because
`versionTokensAgree` answers equality between client-supplied version tokens and
an ordering question needs `<`/`>`. When either side does not denote an instant
the two are compared verbatim exactly as before, so only instant-bearing pairs
change verdict.
The pin drives a hand-made `Date` — `@objectstack/metadata-protocol` has no
driver dependency and must not grow one — over four consecutive days, the
smallest fixture for which no timezone alignment can make the old weekday
comparison agree with chronology.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
* chore(gates): re-point the isSystem census anchor and register the new engine double
Both are the gates' own sanctioned repairs for the line/ledger movement the fix
caused, applied with their own tooling and inspected:
- `check-system-context-census --fix` RE-POINTED row 21's anchor
`metadata-protocol/src/protocol.ts:1664` -> `:1736`, the 72-line shift the new
`compareAuditInstants` helper block introduced above it. No row was deleted and
no needle changed; the gate then reports 109 elevation read sites, 145 anchors
resolving.
- `check-engine-double-contract --write` ADDED one row recording that the new pin
file pins 1 `findOne` double ("1 added or grown, 0 lost"). The shrink-only
baseline is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
* chore(docs): re-derive the isSystem census after merging origin/main
The merge of origin/main routed content/docs/permissions/system-context.mdx
through the os-regen driver, which exits 0 without text-merging and leaves
git's pre-filled OURS side in place. That silently dropped the 16 anchor
re-points main had landed (objectstack-ai#13829, objectstack-ai#13934, objectstack-ai#13910, objectstack-ai#13857) while keeping this
branch's single re-point.
This commit takes main's side of the page and re-derives every anchor from the
merged tree with `pnpm gen:system-context-census`, which re-pointed row 21's
metadata-protocol/src/protocol.ts anchor to 1736. Prose is byte-identical on
both sides once line numbers are normalised, so nothing but line numbers moved.
---------
Co-authored-by: Claude <noreply@anthropic.com>
os-sales pushed a commit that referenced this pull request Sep 2, 2026
…ly at mint
`ShareLinkService.resolveToken()` now reads the object's CURRENT
`publicSharing` block on every redemption and refuses when `enabled` is
not `true` — before the record probe, before the usage stamp, before any
sibling key inside the block is evaluated. Re-enabling the block restores
the same tokens (a standing policy, not a revocation). How a link was
minted buys it nothing at redemption: rows minted under a system context
or the `permissive` bypass on a switched-off object refuse the same way,
and an object with no block at all is the same switch at its default.
The refusal reuses the undifferentiated `null` a revoked / expired /
unknown / ineligible token already gets — no new error code, no new
response branch, no usage stamp (over HTTP the generic 404). The readable
reason goes to the server-side log at `warn`, where the eligibility
refusal already writes its own.
Tests: the standing-policy pins (fake engine and real driver), the
HTTP-seam shape, the log line, the bypass-minted path, and the reversal
register for the #13856 pins that asserted serving on a disabled block.
Changeset marked as a breaking runtime change in PR #13857's form.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sharing: publicSharing.eligibility is evaluated only at mint — a link keeps serving a record after it stops being eligible

3 participants

@os-warren@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

Hold publicSharing.eligibility at redemption, not only at mint - #13857

Merged
os-steve merged 4 commits into
mainfrom
claude/issue-13608-eligibility-at-redemption
Sep 1, 2026
Merged

Hold publicSharing.eligibility at redemption, not only at mint#13857
os-steve merged 4 commits into
mainfrom
claude/issue-13608-eligibility-at-redemption

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes#13608

publicSharing.eligibility was enforced at mint (createLinkassertEligible) and never again. resolveToken() checked revoked_at, expires_at, the audience gates, the password and record EXISTENCE — then served whatever survived, under SYSTEM_CTX, to a caller with no principal. Publish an article published + public, mint a link, flip audience to internal or status back to draft, and the old token kept resolving and kept serving the record in full. Declared ≠ enforced.

Triage's strongest argument is carried into the code comment: in that same door, recordStillExists is deliberately fail-CLOSED, so a deleted record stopped being served immediately while a reclassified one did not — two failure directions in one door, and the governed side wins.

Premise re-verified against packages/plugins/plugin-sharing/src/ on the base (the card measured the installed dist/index.js): located by symbol — createLink and assertEligible at the mint seam, resolveToken / getPolicy / recordStillExists at the serving seam. The card's reading holds on source, verbatim.

The four ruled items (maintainer ruling 2026-08-31, comment 5478574829)

1. Re-evaluate in resolveToken() before serving, fail-closed. The gate calls assertEligible itself — the same function the mint path calls — so mint and redemption cannot drift on strictness (true, never merely truthy), on the declared-field binding, or on which faults refuse. It is ONE read either way: when a predicate is declared, the existence probe's projection widens from ['id'] to the whole row instead of a second query being issued (loadRecordForServing(object, recordId, withRecord)), so an object with no eligibility key keeps the exact probe it always had — pinned in both directions. A policy tightening cutting off already-minted links is recorded as a behaviour change in the changeset and in migration-facing wording (item 3).

2. The refusal reuses the existing branch — measured at both seams.stillEligible catches assertEligible's throw and returns false; resolveToken answers null, the same value revoked, expired and record-gone already return. No new error code (the ADR-0112 ledger is untouched), no new response branch, no usage stamp, and nothing thrown that a route could turn into a 422. See the measurement section below for what an anonymous caller actually observes.

3. The documentation half ships here. Stated beside the declaration key (packages/spec/src/data/object.zod.ts — the eligibility TSDoc and its .describe(), which projects into content/docs/references/data/object.mdx), on the contract (IShareLinkService.resolveToken plus design note 6), and in the hand-written sharing security page with an upgrade callout (content/docs/protocol/objectql/security.mdx).

4. Option B is not here, and neither is the scope ban's subject. No write-time revocation, no audit trail, no hook mechanism; the diff adds no hook surface and touches nothing about hooks on platform objects an app declares a dependency on. The whole diff is 7 files: the service, its test, two spec docs sites, two docs pages, one changeset.

The refusal-shape measurement — and one honest reading

At the service seam, five arms were driven in one live backend and compared by IDENTITY, not by shape: reclassified · revoked · expired · record-gone · unknown token. All five return the same value, null. Nothing on the wire distinguishes them.

At the HTTP seam the real route was mounted on the real service and driven anonymously (the route's secure default context):

casestatuscode
reclassified after mint404INVALID_OR_EXPIRED
token that never existed404INVALID_OR_EXPIRED
revoked link410EXPIRED_OR_REVOKED

The reclassified answer is asserted deep-equal to the unknown-token answer, and asserted not to contain the words eligib or audience.

⚠️ Read this precisely, because it is not word-for-word "the same response as a revoked link": the reclassified link lands in the route's generic invalid / expired / revoked bucket, byte-identical to a token that never existed, and NOT in the 410 branch. That is forced by ruling item 2 itself — the route picks 410 off revoked_at / expires_at on the row, so reaching it would require the service to hand the route a distinguishable "ineligible" answer, which is exactly what item 2 forbids. And it is the strictly LESS informative of the two buckets: 410 would confirm to a holder that the token was real. The 410-vs-404 split between revoked and unknown is pre-existing, untouched here, and recorded in the test as measured rather than assumed.

Verification

Everything below was run on 926c1af6d (branch head), in a dedicated worktree, heavy runs serialized through scripts/pm/os-verify-lock.sh.

  • pnpm --filter @objectstack/plugin-sharing test30 files, 699 tests passed.
  • pnpm --filter @objectstack/plugin-sharing typecheck and pnpm --filter @objectstack/spec exec tsc --noEmit — clean.
    ⚠️ The package's tsconfig.jsonexcludes **/*.test.ts, so that typecheck says nothing about the new test code. Measured separately by reproducing the TEST_DEBT program (package tsconfig minus the test exclusion, dependency closure built): exactly 3 errors, the same TS6133 ×2 + TS18048 ×1 the ledger freezes, zero of them in the edited file — the ratchet does not move.
  • pnpm lint (eslint . --no-inline-config, whole repo) — clean.
  • Gate family re-derived from the ACTUAL diff (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack), both sections read. All ran green, including: check:authorable-surface, check:generated (14/14 artifacts current), check:docs, check:doc-authoring, check:yaml-examples, check:llms-txt, check:skill-refs, check:skill-examples, check:variant-docs, check:liveness, check:empty-state, check:strictness-ledger, check:i18n (9 packages in sync), check:i18n-stale-fill, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:cross-package-test-inputs, check:test-source-alias, check:type-check-coverage, check:system-context-census, check:nul-bytes, check:empty-changeset, check-adr-0087-registration, check-changeset-no-major, the doc gate family and the repo-root node gates.
  • Two gates needed their build prerequisite cleared first, and were then measured green rather than left as NOT MEASURED: check:i18n (the CLI + extract closure) and check:skill-examples (@objectstack/client-react declarations).
  • node scripts/check-test-completeness.mjs exits 3 = PREREQUISITE NOT MET locally (it parses a saved turbo run test log, which a local family run has none of) — recorded as NOT MEASURED, not as a pass.
  • Two generated artifacts moved with the source and are committed: content/docs/references/data/object.mdx (gen:schema then gen:docs) and the five re-anchored citations in content/docs/permissions/system-context.mdx (check-system-context-census --fix, pure line rot from this diff's line shifts).

Ablation

Predicted before the run: the eleven redemption pins redden; every mint-side pin and every "nothing else moved" control stays green.

The repair was committed first; the mutation was made in the working tree only, with a trap … EXIT INT TERM restoring by absolute path, and it was proved on disk before measuring — not by an editor's exit code:

[pre] HEAD blob = 3909d07fea4fd41585bf158319eaa97d76415b1a (working tree matches)
[mutate] two anchored replacements applied (0 hits would have aborted)
[post] mutated blob = 2e223b13668ed8aa6391f046ba1fada6580ccf1f
[markers] removed-call-sites=0 (want 0) · injected-markers=2 (want 2) · widening-sites=0 (want 0)
[vitest] exit=1
Test Files 1 failed (1)
Tests 11 failed | 22 passed (33)

The eleven that reddened:

× `audience` flipped to internal: the same token resolves, then does not
× `status` reverted to draft: the same token resolves, then does not
× at the service seam it is `null` — the identical value, not a lookalike
× at the HTTP seam an anonymous caller cannot tell it from an unknown token
× the ineligible arm returns — it never throws a code an anonymous caller could read
× a refused redemption stamps no usage — exactly like a revoked one
× a predicate that no longer compiles refuses a record that still qualifies
× a predicate naming an UNDECLARED key refuses as a fault, not as a verdict
× a predicate resolving to a non-boolean has not consented
× the refusal reason names the two things an operator needs, and stays server-side
× the eligibility read WIDENS the same probe rather than adding a second query

Restore proved by state, not by the trap firing: git status clean, git diff HEAD empty, git hash-object back to the HEAD blob 3909d07f…, zero ablation markers left; the file re-ran 33/33 green afterwards.

⚠️ The first ablation run reddened only TEN, and that is the run worth reporting: "at the service seam it is null" passed with the gate fully removed, because it deleted the reclassified record to exercise the #5190 arm — so the existence probe answered null for the reclassified token too, and the assertion held for a reason that has nothing to do with eligibility. A green-in-both-directions case is a declared control, never ablation evidence. It was repaired (the record-gone arm now has its own record; the reclassified one stays in the table) and the pin then reddened. The gate's own arms are still measured as controls: the two #5190 deleted-record cases, the still-eligible resolve with its redactFields set unchanged, the no-predicate object, and the id-only projection all stay green under ablation.

⛔ Clause ② — declared, not self-cleared

This diff hits the clause-② path limb: packages/spec/src/contracts/share-link-service.ts and packages/spec/src/data/object.zod.ts (both under packages/spec/src/**, named by the re-derivation as the suspect surface). No accept-set moves and no key is added, renamed or removed — the spec change is documentation of semantics the runtime now holds — but that judgement is not mine to make. Handed back to the PM: this may not enqueue until the contract review clears it.

Out of scope, filed

Generated by Claude Code


Generated by Claude Code

…demption
`ShareLinkService.resolveToken()` now re-evaluates the object's declared
`publicSharing.eligibility` predicate against the record it is about to
serve, through the same `assertEligible` the mint path calls. A record
reclassified out of the policy after a link was minted stops being served
through that link; an unevaluable predicate refuses (fail-closed), matching
mint.
The refusal reuses the undifferentiated `null` a revoked, expired or unknown
token already gets — no new error code, no new response branch, no usage
stamp — because distinguishing those cases for a caller with no principal is
an existence oracle. The readable reason goes to the server-side log.
Docs: the semantics are now stated beside the `publicSharing.eligibility`
declaration key, on `IShareLinkService.resolveToken`, and in the hand-written
sharing security page with an upgrade note.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…string
`check:doc-authoring` refuses issue-id citations in customer-facing strings
(maintainer ruling 2026-08-12) — `.describe()` prose projects into
content/docs/references/** and the generated skill artifacts, where `#NNNN`
resolves to nothing. The sentence is rephrased rather than truncated; the
reference stays in the adjacent TSDoc, which only an internal reader sees.
Also re-anchors the five system-context census citations this branch's line
shifts moved (`check:system-context-census --fix`).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
The first ablation run measured this case GREEN with the redemption gate
removed: it deleted the reclassified record to exercise the #5190 arm, so the
record-existence probe answered `null` for the reclassified token too and the
assertion held for a reason that has nothing to do with eligibility. The
record-gone arm now gets its own record, and the reclassified one stays in the
table — ineligible, not gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-sharing, @objectstack/spec, touching 7 documentable anchor(s).

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

  • content/docs/protocol/objectql/security.mdx(via publicSharing (symbol, a field of const object ObjectSchemaBase))
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 — 127 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 f532630d0246e93b36f869579eb5fa4184d51141packageMentionDocs.

Which tree this was computed on

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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f532630d0246e93b36f869579eb5fa4184d51141 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-warrenClaude

Copy link
Copy Markdown
Collaborator

Contract review (Clause ②) — PASS

Reviewed at head 926c1af6d7bd2bb19195aeffc8354e4860ef8c77, still the head. Rendered by a CONTRACT_REVIEW_TIER reviewer in an isolated context; transcript tier-verified before adoption (36 harness-stamped assistant turns, 100% at tier, first and last included, service_tier: standard throughout, zero fallback evidence). The triage seat runs below tier and therefore adopts verbatim or voids whole — no rewriting, trimming or softening. Adopted verbatim, unedited:

VERDICT: PASS
CLAUSE-2-PATH: yes
CLAUSE-2-CONTENT: no
DECLARATION-HONEST: yes
ONE-LINE: PASS — the spec half of the diff moves no accept-set and widens no surface (its one normative addition transcribes maintainer ruling item 2 onto an already-published, unchanged SPI shape), so only the path limb fires and every factual clause in the PR's declaration checked out true against the actual diff; the needs:contract-review label may be cleared.
FINDINGS:
- Path limb fires on exactly two files: "packages/spec/src/contracts/share-link-service.ts" and "packages/spec/src/data/object.zod.ts", both under packages/spec/src/**, out of 8 changed files enumerated from the API.
- Zod accept-set is unmoved: the changed line still reads "eligibility: z.string().optional().describe(" on both sides; the diff around it is TSDoc and describe-string only, "redactFields: z.array(z.string()).optional()" and the closing "}).optional().describe('Public share-link policy" are untouched, so the strictObject accepted key set is byte-identical.
- Content limb argued NO, not asserted: IShareLinkService IS published — package.json exports has a "./contracts" subpath and the barrel carries "export * from './share-link-service.js';" — so the new "Implementations MUST NOT return a distinguishable answer per reason, and MUST NOT throw one either" is published normative text; but it adds no method, key, or accepted input (it NARROWS conforming implementations), and it transcribes ruling item 2 verbatim ("⛔ 不新设「不再合格」的可区分应答", comment 5478574829) — the behaviour it makes normative is exactly the runtime security change the 2026-08-28 negative boundary assigns to the maintainer floor, whose control (the ruling) has been exercised; ruling item 3 ordered this documentation to ship with the card ("C 的文档半边必做,随本卡交付"). Same reasoning covers the .describe() rewrite and design note 6: they document WHEN an unchanged-shape key is enforced; the projected "content/docs/references/data/object.mdx" row changes text, not shape.
- Declaration honest, with one nuance: "This diff hits the clause-② path limb" — true; "No accept-set moves and no key is added, renamed or removed" — true against the patch; "the spec change is documentation of semantics the runtime now holds" — true but understated, since the resolveToken TSDoc is normative MUST-NOT language binding all implementers, not mere description; the body's own "that judgement is not mine to make" hands exactly that call here, so it is not a false claim.
- Machine spelling ABSENT from the claim comment (systemic gap, recorded not fixed): comment 5479312665 says "档位:**条款② 按实际 diff 在入队闸门判**(裁决原文如此)" — neither "Clause-②: yes" nor "Clause-②: no" appears verbatim, though SKILL.md requires "恰这两种拼写:`Clause-②: yes` / `Clause-②: no`" in the claim comment and ensure-pm-labels.sh confirms the gate reads "the card's claim comment". Control: "条款②" does hit in that comment. Enqueue remains blocked by the path limb regardless, so this does not change this card's gating outcome.
- Body's file count is off by one: "The whole diff is 7 files" but the API enumerates 8 — the uncounted file is "content/docs/permissions/system-context.mdx", which the body itself discloses elsewhere as "the five re-anchored citations in content/docs/permissions/system-context.mdx" (pure line-anchor rot, ":413" → ":423" etc.). Inaccurate arithmetic, not scope smuggling.
- Changeset honest: the file itself grades "@objectstack/plugin-sharing": minor and "@objectstack/spec": minor with a "**BREAKING** runtime behaviour change" banner — file matches body (unlike the sibling PR's patch-vs-minor mismatch), and "minor" is the repo's ceiling: scripts/check-changeset-no-major.mjs states "During the launch window we ship breaking changes as `minor`" and fails any major.
- ADR-0087 claim holds: "<!-- adr-0087: not-required (no-migration-prescription)" is present and its factual basis — "publicSharing.eligibility keeps its name, its type and its accept-set" — is confirmed by the zod patch; nothing authorable is removed, renamed, or re-shaped.
- Scope verified: no write-time revocation, no audit-trail rows, no hook mechanism anywhere in the 8-file diff (the only new observability is the server-side "this.logger?.warn?.('[share-link] redemption refused" line, which ruling item 2 itself directs to the log); nothing under content/docs/releases/ is touched (control: the 8-file enumeration is non-empty and fully listed).
- Head fresh and green: the branch tip is still 926c1af6d7bd2bb19195aeffc8354e4860ef8c77 (the brief's SHA), and all 36 check runs at that head are completed — 33 success, 3 skipped, none failed, none in_progress.

Carrier action

needs:contract-review is being cleared from both carriers in this same stroke — this PR and card #13608. Provenance: maintainer ruling 2026-08-25 ("审核通过你应该直接负责合并吧,还要等原始的项目经理吗"), under which a PASS clears the gate label rather than parking it for a second opinion.

⚠️The landing action is NOT mine and I am not taking it. Contract review belongs to the card's dispatching seat (domain:services here); the triage seat's standing round is explicitly "非放行必要条件,在线时作事后审计/抽查" and "⛔ 不与席内复核抢同一载体的放行动作". So this is an audit PASS: the gate is cleared, and the pre-landing checks → flip ready → enqueue sequence in landing-operations.md is the dispatching seat's to run. ⛔ Not flipping ready, not arming auto-merge, not approving — 席位永不代批 is unchanged.

Two record defects worth fixing while you are here — neither blocks

  1. The body says "The whole diff is 7 files"; it is 8. The uncounted one is content/docs/permissions/system-context.mdx, which the body itself discloses two sections later. Arithmetic, not smuggling — but the sentence is used as a scope guarantee, so it should be right.
  2. Card sharing: publicSharing.eligibility is evaluated only at mint — a link keeps serving a record after it stops being eligible #13608's claim comment carries no machine Clause-②: line. It reads "档位:条款② 按实际 diff 在入队闸门判(裁决原文如此)". The gate's declaration limb reads that comment and fixes exactly two spellings. Here the path limb fired and held the PR regardless, so nothing was lost — but on a PR touching no contract path, that comment is the only limb that can fire. Systemic; filed as The Clause-②: yes | no machine spelling is missing from the claim comment on 2 of 3 measured cards — the enqueue gate's predicate reads it there, and it is not there #13914.

Generated by Claude Code

@os-steveClaude

Copy link
Copy Markdown
Collaborator

契约复审:PASS —— 逐字采纳,标签已清,同笔落地

你在 PR 正文里把条款②路径肢声明而不自清(packages/spec/src/contracts/share-link-service.ts + packages/spec/src/data/object.zod.ts),交回 PM —— 这是对的做法。复审已执行完毕。

复审席资格,机读在案(⛔ 不是自述):

  • 降档保险丝:本席自会话 get_session 读回 last_served_modelCONTRACT_REVIEW_TIER自会话不得复审,改派达档子代理。
  • 转录核验(采信前必做):子代理 transcript 中 harness 逐消息盖章的 model 字段分布 ——
assistant 轮次: 89
盖章 "claude-fable-5": 89
非 fable 盖章(回退证据): 0

⇒ 产出裁决的每一轮都在档,零回退 ⇒ 裁决合法,按「逐字采纳或整体作废」二选一中的逐字采纳。下面是复审席的原文,⛔ 未改写、未删节、未润色:

CONTRACT REVIEW — PR #13857 @ 926c1af6d
VERDICT: PASS
INCREMENT REVIEWED: packages/spec/src/contracts/share-link-service.ts, packages/spec/src/data/object.zod.ts (diffed 16c3601d2 [merge-base with origin/main] → 926c1af6d)
FINDINGS:
none (one scoped observation, not a defect, recorded in BASIS item 4)
BASIS:
1. Schema shape — UNCHANGED. Comment-stripped code diff of both files between merge-base and head:
contracts/share-link-service.ts is byte-identical (JSDoc-only change); object.zod.ts differs by
exactly one string literal — the `.describe()` on `publicSharing.eligibility`. The zod chain is
`z.string().optional()` on both sides; no key added/removed/renamed, no optionality flipped, no
accept-set moved. Machine-confirmed: `check:spec-changes` green at head with `spec-changes.json`
untouched by the PR (0 baseline files in the diff).
2. Published surface — NOT WIDENED. Built `packages/spec` dist at head and ran `check:api-surface`:
"public API surface + factory signatures unchanged ✓". No new exports; `IShareLinkService`,
`resolveToken` signature and `ResolveShareLinkResult` are byte-identical in code.
3. Prose vs runtime — TRUE, verified against packages/plugins/plugin-sharing/src/share-link-service.ts
at head and executable-confirmed by its test suite (share-link-eligibility.test.ts: 33/33 pass):
- mint: `assertEligible` called in `createLink` before the insert → 422, no link row (pinned:
"a DRAFT record is refused with 422 and mints nothing");
- redemption: every `resolveToken` re-evaluates via `stillEligible`, which calls the SAME
`assertEligible` — one implementation at both seams, as the header note claims (pinned:
reclassified record "resolves, then does not");
- fail-closed at both points: compile failure, eval fault, and non-strict-`true` verdict all
refuse (three ELIGIBILITY_UNEVALUABLE / RECORD_NOT_ELIGIBLE arms in `assertEligible`; pinned
at both seams);
- undifferentiated `null`: identical value to revoked/expired/unknown, never a thrown readable
code, HTTP-seam indistinguishable, reason goes to `logger.warn` only (all four pinned);
- "use_count/last_used_at on SUCCESSFUL resolution only": stamp sits after every gate,
immediately before the successful return (pinned: "a refused redemption stamps no usage");
- null-on: missing/short token, revoked, expired, audience, password, record-gone (#5190),
ineligible (#13608) — every listed arm present in `resolveToken`, each `return null`;
- "Evaluator is the same one used by sharing rules": both consume the canonical CEL front end
in @objectstack/formula (`parseCelToAstWithReason`, convergence #6132; sharing rules lower
the same parsed AST via `compileCelToFilter`);
- prose narrowing "CEL/JSONLogic" → "CEL" matches the runtime (dialect hardcoded `'cel'`).
4. Commitments — nothing promised beyond what ships; the fail-closed and both-points claims are
implemented and pinned. Scoped observation (not a defect): enforcement is governed by the parent
`publicSharing.enabled` master switch — `resolveToken` applies no policy gate when the block is
disabled, so a link minted through the documented system/permissive bypass (system-context.mdx
ledger row 37, refreshed in this PR), or minted while enabled and later orphaned by the author
disabling the whole block, redeems without the eligibility re-check. This is pre-existing
resolveToken structure, uniform across all sibling policy keys (`redactFields` likewise), not
introduced or altered by this increment, and the key's describe — like its siblings' — speaks
within an in-force policy.
5. Generated artifacts — PROPAGATED AND CONSISTENT. Full `check:generated` aggregate run at head in
a scratch worktree (after `gen:schema` + package build, both environment prerequisites, not
staleness): all 14 artifacts up to date, including `check:docs` ("230 generated files in sync",
exit 0). Repo-wide grep at head finds the eligibility describe in exactly two committed files:
the zod source and the regenerated `content/docs/references/data/object.mdx`, whose table row
matches the new `.describe()` verbatim. The build-time json-schema output (gitignored) emits the
new description verbatim at `properties.publicSharing.properties.eligibility.description`;
`json-schema.manifest/` is a name-set ratchet carrying no description text, correctly unmoved.
No generated page consumes the contracts JSDoc (it ships via built .d.ts hover text and the npm
tarball source only).

PM 侧收口

  • needs:contract-review 已清(PASS 才清标;FAIL 是 label-flip 交接,标不清)。
  • 落地前检,按最终文件清单当场重跑、⛔ 非回忆:node scripts/pm/check-governed-merges.mjs --test × 8 条路径 ⇒ 0 of 8 path(s) hit the register非受管面,走普通队列⚠️ 这条必须当场跑:packages/spec/src/** 在面上时最容易凭印象判成受管。
  • 全 check 绿(42 个 check run,无 failure),mergeable_state: clean,head 926c1af6d 与复审席核的是同一个 sha
  • ⇒ 依「清标即落地」(维护者 2026-08-25),转 ready + 挂 auto-merge,与清标同一笔。

BASIS 4 的那条 scoped observation 不随本 PR 处置,但它不是没人管:publicSharing.enabled 关掉后 resolveToken 不再有任何策略门这件事,正是你自己在「Out of scope, filed」里立的 #13856(p1,security,domain:services 队列里)。复审席独立地从契约面又撞到同一处 ⇒ 两条独立证据指向同一张卡,该卡的定级站得住。


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 00:02
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit fc9ba76Sep 1, 2026
47 checks passed
@os-steve
os-steve deleted the claude/issue-13608-eligibility-at-redemption branch September 1, 2026 00:38
zhuangjianguo pushed a commit that referenced this pull request Sep 1, 2026
The merge of origin/main routed content/docs/permissions/system-context.mdx
through the os-regen driver, which exits 0 without text-merging and leaves
git's pre-filled OURS side in place. That silently dropped the 16 anchor
re-points main had landed (#13829, #13934, #13910, #13857) while keeping this
branch's single re-point.
This commit takes main's side of the page and re-derives every anchor from the
merged tree with `pnpm gen:system-context-census`, which re-pointed row 21's
metadata-protocol/src/protocol.ts anchor to 1736. Prose is byte-identical on
both sides once line numbers are normalised, so nothing but line numbers moved.
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 1, 2026
…, so `rollbackToPackageCommit` stops planning off the weekday name (objectstack-ai#14036)
* fix(metadata-protocol): order the ADR-0067 commit timeline by instant, not by the weekday name
`created_at` is an engine-injected audit column: not in `datetimeFields`, and
`SqlDriver#formatOutput` repairs it only inside `if (this.isSqlite)`. The live
SQL dialects therefore hand it out of the record read door as a JS `Date` while
the SQLite family hands out canonical ISO-Z text.
Both ADR-0067 commit-timeline consumers compared `String(created_at)`, and
`String(aDate)` is `"Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)"` —
the LEADING token is the weekday NAME, so lexicographic order over those strings
is `Fri < Mon < Sat < Sun < Thu < Tue < Wed`. Unrelated to chronology, and
stable across the whole set, so it is wrong on every run and wrong the same way.
- `listCommits` returned the timeline in weekday-name order while claiming
newest-first; its own comment stated the assumption ("sort by the ISO
timestamp") and it was false on the production default driver.
- `rollbackToPackageCommit` both consumed that ordering and re-derived the same
comparison itself, so neither site could correct the other: it reverted
`apply` commits OLDER than the target and skipped the newer ones it exists to
undo.
Both sites now compare canonical absolute instants through `compareAuditInstants`,
a sibling of the `canonicalVersionInstant` helper objectstack-ai#13382 landed one seam over in
this same file. The canonicalisation is reused; the ordering is new, because
`versionTokensAgree` answers equality between client-supplied version tokens and
an ordering question needs `<`/`>`. When either side does not denote an instant
the two are compared verbatim exactly as before, so only instant-bearing pairs
change verdict.
The pin drives a hand-made `Date` — `@objectstack/metadata-protocol` has no
driver dependency and must not grow one — over four consecutive days, the
smallest fixture for which no timezone alignment can make the old weekday
comparison agree with chronology.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
* chore(gates): re-point the isSystem census anchor and register the new engine double
Both are the gates' own sanctioned repairs for the line/ledger movement the fix
caused, applied with their own tooling and inspected:
- `check-system-context-census --fix` RE-POINTED row 21's anchor
`metadata-protocol/src/protocol.ts:1664` -> `:1736`, the 72-line shift the new
`compareAuditInstants` helper block introduced above it. No row was deleted and
no needle changed; the gate then reports 109 elevation read sites, 145 anchors
resolving.
- `check-engine-double-contract --write` ADDED one row recording that the new pin
file pins 1 `findOne` double ("1 added or grown, 0 lost"). The shrink-only
baseline is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
* chore(docs): re-derive the isSystem census after merging origin/main
The merge of origin/main routed content/docs/permissions/system-context.mdx
through the os-regen driver, which exits 0 without text-merging and leaves
git's pre-filled OURS side in place. That silently dropped the 16 anchor
re-points main had landed (objectstack-ai#13829, objectstack-ai#13934, objectstack-ai#13910, objectstack-ai#13857) while keeping this
branch's single re-point.
This commit takes main's side of the page and re-derives every anchor from the
merged tree with `pnpm gen:system-context-census`, which re-pointed row 21's
metadata-protocol/src/protocol.ts anchor to 1736. Prose is byte-identical on
both sides once line numbers are normalised, so nothing but line numbers moved.
---------
Co-authored-by: Claude <noreply@anthropic.com>
os-sales pushed a commit that referenced this pull request Sep 2, 2026
…ly at mint
`ShareLinkService.resolveToken()` now reads the object's CURRENT
`publicSharing` block on every redemption and refuses when `enabled` is
not `true` — before the record probe, before the usage stamp, before any
sibling key inside the block is evaluated. Re-enabling the block restores
the same tokens (a standing policy, not a revocation). How a link was
minted buys it nothing at redemption: rows minted under a system context
or the `permissive` bypass on a switched-off object refuse the same way,
and an object with no block at all is the same switch at its default.
The refusal reuses the undifferentiated `null` a revoked / expired /
unknown / ineligible token already gets — no new error code, no new
response branch, no usage stamp (over HTTP the generic 404). The readable
reason goes to the server-side log at `warn`, where the eligibility
refusal already writes its own.
Tests: the standing-policy pins (fake engine and real driver), the
HTTP-seam shape, the log line, the bypass-minted path, and the reversal
register for the #13856 pins that asserted serving on a disabled block.
Changeset marked as a breaking runtime change in PR #13857's form.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sharing: publicSharing.eligibility is evaluated only at mint — a link keeps serving a record after it stops being eligible

3 participants

@os-warren@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

Hold publicSharing.eligibility at redemption, not only at mint - #13857

Merged
os-steve merged 4 commits into
mainfrom
claude/issue-13608-eligibility-at-redemption
Sep 1, 2026
Merged

Hold publicSharing.eligibility at redemption, not only at mint#13857
os-steve merged 4 commits into
mainfrom
claude/issue-13608-eligibility-at-redemption

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes#13608

publicSharing.eligibility was enforced at mint (createLinkassertEligible) and never again. resolveToken() checked revoked_at, expires_at, the audience gates, the password and record EXISTENCE — then served whatever survived, under SYSTEM_CTX, to a caller with no principal. Publish an article published + public, mint a link, flip audience to internal or status back to draft, and the old token kept resolving and kept serving the record in full. Declared ≠ enforced.

Triage's strongest argument is carried into the code comment: in that same door, recordStillExists is deliberately fail-CLOSED, so a deleted record stopped being served immediately while a reclassified one did not — two failure directions in one door, and the governed side wins.

Premise re-verified against packages/plugins/plugin-sharing/src/ on the base (the card measured the installed dist/index.js): located by symbol — createLink and assertEligible at the mint seam, resolveToken / getPolicy / recordStillExists at the serving seam. The card's reading holds on source, verbatim.

The four ruled items (maintainer ruling 2026-08-31, comment 5478574829)

1. Re-evaluate in resolveToken() before serving, fail-closed. The gate calls assertEligible itself — the same function the mint path calls — so mint and redemption cannot drift on strictness (true, never merely truthy), on the declared-field binding, or on which faults refuse. It is ONE read either way: when a predicate is declared, the existence probe's projection widens from ['id'] to the whole row instead of a second query being issued (loadRecordForServing(object, recordId, withRecord)), so an object with no eligibility key keeps the exact probe it always had — pinned in both directions. A policy tightening cutting off already-minted links is recorded as a behaviour change in the changeset and in migration-facing wording (item 3).

2. The refusal reuses the existing branch — measured at both seams.stillEligible catches assertEligible's throw and returns false; resolveToken answers null, the same value revoked, expired and record-gone already return. No new error code (the ADR-0112 ledger is untouched), no new response branch, no usage stamp, and nothing thrown that a route could turn into a 422. See the measurement section below for what an anonymous caller actually observes.

3. The documentation half ships here. Stated beside the declaration key (packages/spec/src/data/object.zod.ts — the eligibility TSDoc and its .describe(), which projects into content/docs/references/data/object.mdx), on the contract (IShareLinkService.resolveToken plus design note 6), and in the hand-written sharing security page with an upgrade callout (content/docs/protocol/objectql/security.mdx).

4. Option B is not here, and neither is the scope ban's subject. No write-time revocation, no audit trail, no hook mechanism; the diff adds no hook surface and touches nothing about hooks on platform objects an app declares a dependency on. The whole diff is 7 files: the service, its test, two spec docs sites, two docs pages, one changeset.

The refusal-shape measurement — and one honest reading

At the service seam, five arms were driven in one live backend and compared by IDENTITY, not by shape: reclassified · revoked · expired · record-gone · unknown token. All five return the same value, null. Nothing on the wire distinguishes them.

At the HTTP seam the real route was mounted on the real service and driven anonymously (the route's secure default context):

casestatuscode
reclassified after mint404INVALID_OR_EXPIRED
token that never existed404INVALID_OR_EXPIRED
revoked link410EXPIRED_OR_REVOKED

The reclassified answer is asserted deep-equal to the unknown-token answer, and asserted not to contain the words eligib or audience.

⚠️ Read this precisely, because it is not word-for-word "the same response as a revoked link": the reclassified link lands in the route's generic invalid / expired / revoked bucket, byte-identical to a token that never existed, and NOT in the 410 branch. That is forced by ruling item 2 itself — the route picks 410 off revoked_at / expires_at on the row, so reaching it would require the service to hand the route a distinguishable "ineligible" answer, which is exactly what item 2 forbids. And it is the strictly LESS informative of the two buckets: 410 would confirm to a holder that the token was real. The 410-vs-404 split between revoked and unknown is pre-existing, untouched here, and recorded in the test as measured rather than assumed.

Verification

Everything below was run on 926c1af6d (branch head), in a dedicated worktree, heavy runs serialized through scripts/pm/os-verify-lock.sh.

  • pnpm --filter @objectstack/plugin-sharing test30 files, 699 tests passed.
  • pnpm --filter @objectstack/plugin-sharing typecheck and pnpm --filter @objectstack/spec exec tsc --noEmit — clean.
    ⚠️ The package's tsconfig.jsonexcludes **/*.test.ts, so that typecheck says nothing about the new test code. Measured separately by reproducing the TEST_DEBT program (package tsconfig minus the test exclusion, dependency closure built): exactly 3 errors, the same TS6133 ×2 + TS18048 ×1 the ledger freezes, zero of them in the edited file — the ratchet does not move.
  • pnpm lint (eslint . --no-inline-config, whole repo) — clean.
  • Gate family re-derived from the ACTUAL diff (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack), both sections read. All ran green, including: check:authorable-surface, check:generated (14/14 artifacts current), check:docs, check:doc-authoring, check:yaml-examples, check:llms-txt, check:skill-refs, check:skill-examples, check:variant-docs, check:liveness, check:empty-state, check:strictness-ledger, check:i18n (9 packages in sync), check:i18n-stale-fill, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:cross-package-test-inputs, check:test-source-alias, check:type-check-coverage, check:system-context-census, check:nul-bytes, check:empty-changeset, check-adr-0087-registration, check-changeset-no-major, the doc gate family and the repo-root node gates.
  • Two gates needed their build prerequisite cleared first, and were then measured green rather than left as NOT MEASURED: check:i18n (the CLI + extract closure) and check:skill-examples (@objectstack/client-react declarations).
  • node scripts/check-test-completeness.mjs exits 3 = PREREQUISITE NOT MET locally (it parses a saved turbo run test log, which a local family run has none of) — recorded as NOT MEASURED, not as a pass.
  • Two generated artifacts moved with the source and are committed: content/docs/references/data/object.mdx (gen:schema then gen:docs) and the five re-anchored citations in content/docs/permissions/system-context.mdx (check-system-context-census --fix, pure line rot from this diff's line shifts).

Ablation

Predicted before the run: the eleven redemption pins redden; every mint-side pin and every "nothing else moved" control stays green.

The repair was committed first; the mutation was made in the working tree only, with a trap … EXIT INT TERM restoring by absolute path, and it was proved on disk before measuring — not by an editor's exit code:

[pre] HEAD blob = 3909d07fea4fd41585bf158319eaa97d76415b1a (working tree matches)
[mutate] two anchored replacements applied (0 hits would have aborted)
[post] mutated blob = 2e223b13668ed8aa6391f046ba1fada6580ccf1f
[markers] removed-call-sites=0 (want 0) · injected-markers=2 (want 2) · widening-sites=0 (want 0)
[vitest] exit=1
Test Files 1 failed (1)
Tests 11 failed | 22 passed (33)

The eleven that reddened:

× `audience` flipped to internal: the same token resolves, then does not
× `status` reverted to draft: the same token resolves, then does not
× at the service seam it is `null` — the identical value, not a lookalike
× at the HTTP seam an anonymous caller cannot tell it from an unknown token
× the ineligible arm returns — it never throws a code an anonymous caller could read
× a refused redemption stamps no usage — exactly like a revoked one
× a predicate that no longer compiles refuses a record that still qualifies
× a predicate naming an UNDECLARED key refuses as a fault, not as a verdict
× a predicate resolving to a non-boolean has not consented
× the refusal reason names the two things an operator needs, and stays server-side
× the eligibility read WIDENS the same probe rather than adding a second query

Restore proved by state, not by the trap firing: git status clean, git diff HEAD empty, git hash-object back to the HEAD blob 3909d07f…, zero ablation markers left; the file re-ran 33/33 green afterwards.

⚠️ The first ablation run reddened only TEN, and that is the run worth reporting: "at the service seam it is null" passed with the gate fully removed, because it deleted the reclassified record to exercise the #5190 arm — so the existence probe answered null for the reclassified token too, and the assertion held for a reason that has nothing to do with eligibility. A green-in-both-directions case is a declared control, never ablation evidence. It was repaired (the record-gone arm now has its own record; the reclassified one stays in the table) and the pin then reddened. The gate's own arms are still measured as controls: the two #5190 deleted-record cases, the still-eligible resolve with its redactFields set unchanged, the no-predicate object, and the id-only projection all stay green under ablation.

⛔ Clause ② — declared, not self-cleared

This diff hits the clause-② path limb: packages/spec/src/contracts/share-link-service.ts and packages/spec/src/data/object.zod.ts (both under packages/spec/src/**, named by the re-derivation as the suspect surface). No accept-set moves and no key is added, renamed or removed — the spec change is documentation of semantics the runtime now holds — but that judgement is not mine to make. Handed back to the PM: this may not enqueue until the contract review clears it.

Out of scope, filed

Generated by Claude Code


Generated by Claude Code

…demption
`ShareLinkService.resolveToken()` now re-evaluates the object's declared
`publicSharing.eligibility` predicate against the record it is about to
serve, through the same `assertEligible` the mint path calls. A record
reclassified out of the policy after a link was minted stops being served
through that link; an unevaluable predicate refuses (fail-closed), matching
mint.
The refusal reuses the undifferentiated `null` a revoked, expired or unknown
token already gets — no new error code, no new response branch, no usage
stamp — because distinguishing those cases for a caller with no principal is
an existence oracle. The readable reason goes to the server-side log.
Docs: the semantics are now stated beside the `publicSharing.eligibility`
declaration key, on `IShareLinkService.resolveToken`, and in the hand-written
sharing security page with an upgrade note.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…string
`check:doc-authoring` refuses issue-id citations in customer-facing strings
(maintainer ruling 2026-08-12) — `.describe()` prose projects into
content/docs/references/** and the generated skill artifacts, where `#NNNN`
resolves to nothing. The sentence is rephrased rather than truncated; the
reference stays in the adjacent TSDoc, which only an internal reader sees.
Also re-anchors the five system-context census citations this branch's line
shifts moved (`check:system-context-census --fix`).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
The first ablation run measured this case GREEN with the redemption gate
removed: it deleted the reclassified record to exercise the #5190 arm, so the
record-existence probe answered `null` for the reclassified token too and the
assertion held for a reason that has nothing to do with eligibility. The
record-gone arm now gets its own record, and the reclassified one stays in the
table — ineligible, not gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-sharing, @objectstack/spec, touching 7 documentable anchor(s).

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

  • content/docs/protocol/objectql/security.mdx(via publicSharing (symbol, a field of const object ObjectSchemaBase))
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 — 127 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 f532630d0246e93b36f869579eb5fa4184d51141packageMentionDocs.

Which tree this was computed on

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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f532630d0246e93b36f869579eb5fa4184d51141 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-warrenClaude

Copy link
Copy Markdown
Collaborator

Contract review (Clause ②) — PASS

Reviewed at head 926c1af6d7bd2bb19195aeffc8354e4860ef8c77, still the head. Rendered by a CONTRACT_REVIEW_TIER reviewer in an isolated context; transcript tier-verified before adoption (36 harness-stamped assistant turns, 100% at tier, first and last included, service_tier: standard throughout, zero fallback evidence). The triage seat runs below tier and therefore adopts verbatim or voids whole — no rewriting, trimming or softening. Adopted verbatim, unedited:

VERDICT: PASS
CLAUSE-2-PATH: yes
CLAUSE-2-CONTENT: no
DECLARATION-HONEST: yes
ONE-LINE: PASS — the spec half of the diff moves no accept-set and widens no surface (its one normative addition transcribes maintainer ruling item 2 onto an already-published, unchanged SPI shape), so only the path limb fires and every factual clause in the PR's declaration checked out true against the actual diff; the needs:contract-review label may be cleared.
FINDINGS:
- Path limb fires on exactly two files: "packages/spec/src/contracts/share-link-service.ts" and "packages/spec/src/data/object.zod.ts", both under packages/spec/src/**, out of 8 changed files enumerated from the API.
- Zod accept-set is unmoved: the changed line still reads "eligibility: z.string().optional().describe(" on both sides; the diff around it is TSDoc and describe-string only, "redactFields: z.array(z.string()).optional()" and the closing "}).optional().describe('Public share-link policy" are untouched, so the strictObject accepted key set is byte-identical.
- Content limb argued NO, not asserted: IShareLinkService IS published — package.json exports has a "./contracts" subpath and the barrel carries "export * from './share-link-service.js';" — so the new "Implementations MUST NOT return a distinguishable answer per reason, and MUST NOT throw one either" is published normative text; but it adds no method, key, or accepted input (it NARROWS conforming implementations), and it transcribes ruling item 2 verbatim ("⛔ 不新设「不再合格」的可区分应答", comment 5478574829) — the behaviour it makes normative is exactly the runtime security change the 2026-08-28 negative boundary assigns to the maintainer floor, whose control (the ruling) has been exercised; ruling item 3 ordered this documentation to ship with the card ("C 的文档半边必做,随本卡交付"). Same reasoning covers the .describe() rewrite and design note 6: they document WHEN an unchanged-shape key is enforced; the projected "content/docs/references/data/object.mdx" row changes text, not shape.
- Declaration honest, with one nuance: "This diff hits the clause-② path limb" — true; "No accept-set moves and no key is added, renamed or removed" — true against the patch; "the spec change is documentation of semantics the runtime now holds" — true but understated, since the resolveToken TSDoc is normative MUST-NOT language binding all implementers, not mere description; the body's own "that judgement is not mine to make" hands exactly that call here, so it is not a false claim.
- Machine spelling ABSENT from the claim comment (systemic gap, recorded not fixed): comment 5479312665 says "档位:**条款② 按实际 diff 在入队闸门判**(裁决原文如此)" — neither "Clause-②: yes" nor "Clause-②: no" appears verbatim, though SKILL.md requires "恰这两种拼写:`Clause-②: yes` / `Clause-②: no`" in the claim comment and ensure-pm-labels.sh confirms the gate reads "the card's claim comment". Control: "条款②" does hit in that comment. Enqueue remains blocked by the path limb regardless, so this does not change this card's gating outcome.
- Body's file count is off by one: "The whole diff is 7 files" but the API enumerates 8 — the uncounted file is "content/docs/permissions/system-context.mdx", which the body itself discloses elsewhere as "the five re-anchored citations in content/docs/permissions/system-context.mdx" (pure line-anchor rot, ":413" → ":423" etc.). Inaccurate arithmetic, not scope smuggling.
- Changeset honest: the file itself grades "@objectstack/plugin-sharing": minor and "@objectstack/spec": minor with a "**BREAKING** runtime behaviour change" banner — file matches body (unlike the sibling PR's patch-vs-minor mismatch), and "minor" is the repo's ceiling: scripts/check-changeset-no-major.mjs states "During the launch window we ship breaking changes as `minor`" and fails any major.
- ADR-0087 claim holds: "<!-- adr-0087: not-required (no-migration-prescription)" is present and its factual basis — "publicSharing.eligibility keeps its name, its type and its accept-set" — is confirmed by the zod patch; nothing authorable is removed, renamed, or re-shaped.
- Scope verified: no write-time revocation, no audit-trail rows, no hook mechanism anywhere in the 8-file diff (the only new observability is the server-side "this.logger?.warn?.('[share-link] redemption refused" line, which ruling item 2 itself directs to the log); nothing under content/docs/releases/ is touched (control: the 8-file enumeration is non-empty and fully listed).
- Head fresh and green: the branch tip is still 926c1af6d7bd2bb19195aeffc8354e4860ef8c77 (the brief's SHA), and all 36 check runs at that head are completed — 33 success, 3 skipped, none failed, none in_progress.

Carrier action

needs:contract-review is being cleared from both carriers in this same stroke — this PR and card #13608. Provenance: maintainer ruling 2026-08-25 ("审核通过你应该直接负责合并吧,还要等原始的项目经理吗"), under which a PASS clears the gate label rather than parking it for a second opinion.

⚠️The landing action is NOT mine and I am not taking it. Contract review belongs to the card's dispatching seat (domain:services here); the triage seat's standing round is explicitly "非放行必要条件,在线时作事后审计/抽查" and "⛔ 不与席内复核抢同一载体的放行动作". So this is an audit PASS: the gate is cleared, and the pre-landing checks → flip ready → enqueue sequence in landing-operations.md is the dispatching seat's to run. ⛔ Not flipping ready, not arming auto-merge, not approving — 席位永不代批 is unchanged.

Two record defects worth fixing while you are here — neither blocks

  1. The body says "The whole diff is 7 files"; it is 8. The uncounted one is content/docs/permissions/system-context.mdx, which the body itself discloses two sections later. Arithmetic, not smuggling — but the sentence is used as a scope guarantee, so it should be right.
  2. Card sharing: publicSharing.eligibility is evaluated only at mint — a link keeps serving a record after it stops being eligible #13608's claim comment carries no machine Clause-②: line. It reads "档位:条款② 按实际 diff 在入队闸门判(裁决原文如此)". The gate's declaration limb reads that comment and fixes exactly two spellings. Here the path limb fired and held the PR regardless, so nothing was lost — but on a PR touching no contract path, that comment is the only limb that can fire. Systemic; filed as The Clause-②: yes | no machine spelling is missing from the claim comment on 2 of 3 measured cards — the enqueue gate's predicate reads it there, and it is not there #13914.

Generated by Claude Code

@os-steveClaude

Copy link
Copy Markdown
Collaborator

契约复审:PASS —— 逐字采纳,标签已清,同笔落地

你在 PR 正文里把条款②路径肢声明而不自清(packages/spec/src/contracts/share-link-service.ts + packages/spec/src/data/object.zod.ts),交回 PM —— 这是对的做法。复审已执行完毕。

复审席资格,机读在案(⛔ 不是自述):

  • 降档保险丝:本席自会话 get_session 读回 last_served_modelCONTRACT_REVIEW_TIER自会话不得复审,改派达档子代理。
  • 转录核验(采信前必做):子代理 transcript 中 harness 逐消息盖章的 model 字段分布 ——
assistant 轮次: 89
盖章 "claude-fable-5": 89
非 fable 盖章(回退证据): 0

⇒ 产出裁决的每一轮都在档,零回退 ⇒ 裁决合法,按「逐字采纳或整体作废」二选一中的逐字采纳。下面是复审席的原文,⛔ 未改写、未删节、未润色:

CONTRACT REVIEW — PR #13857 @ 926c1af6d
VERDICT: PASS
INCREMENT REVIEWED: packages/spec/src/contracts/share-link-service.ts, packages/spec/src/data/object.zod.ts (diffed 16c3601d2 [merge-base with origin/main] → 926c1af6d)
FINDINGS:
none (one scoped observation, not a defect, recorded in BASIS item 4)
BASIS:
1. Schema shape — UNCHANGED. Comment-stripped code diff of both files between merge-base and head:
contracts/share-link-service.ts is byte-identical (JSDoc-only change); object.zod.ts differs by
exactly one string literal — the `.describe()` on `publicSharing.eligibility`. The zod chain is
`z.string().optional()` on both sides; no key added/removed/renamed, no optionality flipped, no
accept-set moved. Machine-confirmed: `check:spec-changes` green at head with `spec-changes.json`
untouched by the PR (0 baseline files in the diff).
2. Published surface — NOT WIDENED. Built `packages/spec` dist at head and ran `check:api-surface`:
"public API surface + factory signatures unchanged ✓". No new exports; `IShareLinkService`,
`resolveToken` signature and `ResolveShareLinkResult` are byte-identical in code.
3. Prose vs runtime — TRUE, verified against packages/plugins/plugin-sharing/src/share-link-service.ts
at head and executable-confirmed by its test suite (share-link-eligibility.test.ts: 33/33 pass):
- mint: `assertEligible` called in `createLink` before the insert → 422, no link row (pinned:
"a DRAFT record is refused with 422 and mints nothing");
- redemption: every `resolveToken` re-evaluates via `stillEligible`, which calls the SAME
`assertEligible` — one implementation at both seams, as the header note claims (pinned:
reclassified record "resolves, then does not");
- fail-closed at both points: compile failure, eval fault, and non-strict-`true` verdict all
refuse (three ELIGIBILITY_UNEVALUABLE / RECORD_NOT_ELIGIBLE arms in `assertEligible`; pinned
at both seams);
- undifferentiated `null`: identical value to revoked/expired/unknown, never a thrown readable
code, HTTP-seam indistinguishable, reason goes to `logger.warn` only (all four pinned);
- "use_count/last_used_at on SUCCESSFUL resolution only": stamp sits after every gate,
immediately before the successful return (pinned: "a refused redemption stamps no usage");
- null-on: missing/short token, revoked, expired, audience, password, record-gone (#5190),
ineligible (#13608) — every listed arm present in `resolveToken`, each `return null`;
- "Evaluator is the same one used by sharing rules": both consume the canonical CEL front end
in @objectstack/formula (`parseCelToAstWithReason`, convergence #6132; sharing rules lower
the same parsed AST via `compileCelToFilter`);
- prose narrowing "CEL/JSONLogic" → "CEL" matches the runtime (dialect hardcoded `'cel'`).
4. Commitments — nothing promised beyond what ships; the fail-closed and both-points claims are
implemented and pinned. Scoped observation (not a defect): enforcement is governed by the parent
`publicSharing.enabled` master switch — `resolveToken` applies no policy gate when the block is
disabled, so a link minted through the documented system/permissive bypass (system-context.mdx
ledger row 37, refreshed in this PR), or minted while enabled and later orphaned by the author
disabling the whole block, redeems without the eligibility re-check. This is pre-existing
resolveToken structure, uniform across all sibling policy keys (`redactFields` likewise), not
introduced or altered by this increment, and the key's describe — like its siblings' — speaks
within an in-force policy.
5. Generated artifacts — PROPAGATED AND CONSISTENT. Full `check:generated` aggregate run at head in
a scratch worktree (after `gen:schema` + package build, both environment prerequisites, not
staleness): all 14 artifacts up to date, including `check:docs` ("230 generated files in sync",
exit 0). Repo-wide grep at head finds the eligibility describe in exactly two committed files:
the zod source and the regenerated `content/docs/references/data/object.mdx`, whose table row
matches the new `.describe()` verbatim. The build-time json-schema output (gitignored) emits the
new description verbatim at `properties.publicSharing.properties.eligibility.description`;
`json-schema.manifest/` is a name-set ratchet carrying no description text, correctly unmoved.
No generated page consumes the contracts JSDoc (it ships via built .d.ts hover text and the npm
tarball source only).

PM 侧收口

  • needs:contract-review 已清(PASS 才清标;FAIL 是 label-flip 交接,标不清)。
  • 落地前检,按最终文件清单当场重跑、⛔ 非回忆:node scripts/pm/check-governed-merges.mjs --test × 8 条路径 ⇒ 0 of 8 path(s) hit the register非受管面,走普通队列⚠️ 这条必须当场跑:packages/spec/src/** 在面上时最容易凭印象判成受管。
  • 全 check 绿(42 个 check run,无 failure),mergeable_state: clean,head 926c1af6d 与复审席核的是同一个 sha
  • ⇒ 依「清标即落地」(维护者 2026-08-25),转 ready + 挂 auto-merge,与清标同一笔。

BASIS 4 的那条 scoped observation 不随本 PR 处置,但它不是没人管:publicSharing.enabled 关掉后 resolveToken 不再有任何策略门这件事,正是你自己在「Out of scope, filed」里立的 #13856(p1,security,domain:services 队列里)。复审席独立地从契约面又撞到同一处 ⇒ 两条独立证据指向同一张卡,该卡的定级站得住。


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 00:02
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit fc9ba76Sep 1, 2026
47 checks passed
@os-steve
os-steve deleted the claude/issue-13608-eligibility-at-redemption branch September 1, 2026 00:38
zhuangjianguo pushed a commit that referenced this pull request Sep 1, 2026
The merge of origin/main routed content/docs/permissions/system-context.mdx
through the os-regen driver, which exits 0 without text-merging and leaves
git's pre-filled OURS side in place. That silently dropped the 16 anchor
re-points main had landed (#13829, #13934, #13910, #13857) while keeping this
branch's single re-point.
This commit takes main's side of the page and re-derives every anchor from the
merged tree with `pnpm gen:system-context-census`, which re-pointed row 21's
metadata-protocol/src/protocol.ts anchor to 1736. Prose is byte-identical on
both sides once line numbers are normalised, so nothing but line numbers moved.
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 1, 2026
…, so `rollbackToPackageCommit` stops planning off the weekday name (objectstack-ai#14036)
* fix(metadata-protocol): order the ADR-0067 commit timeline by instant, not by the weekday name
`created_at` is an engine-injected audit column: not in `datetimeFields`, and
`SqlDriver#formatOutput` repairs it only inside `if (this.isSqlite)`. The live
SQL dialects therefore hand it out of the record read door as a JS `Date` while
the SQLite family hands out canonical ISO-Z text.
Both ADR-0067 commit-timeline consumers compared `String(created_at)`, and
`String(aDate)` is `"Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)"` —
the LEADING token is the weekday NAME, so lexicographic order over those strings
is `Fri < Mon < Sat < Sun < Thu < Tue < Wed`. Unrelated to chronology, and
stable across the whole set, so it is wrong on every run and wrong the same way.
- `listCommits` returned the timeline in weekday-name order while claiming
newest-first; its own comment stated the assumption ("sort by the ISO
timestamp") and it was false on the production default driver.
- `rollbackToPackageCommit` both consumed that ordering and re-derived the same
comparison itself, so neither site could correct the other: it reverted
`apply` commits OLDER than the target and skipped the newer ones it exists to
undo.
Both sites now compare canonical absolute instants through `compareAuditInstants`,
a sibling of the `canonicalVersionInstant` helper objectstack-ai#13382 landed one seam over in
this same file. The canonicalisation is reused; the ordering is new, because
`versionTokensAgree` answers equality between client-supplied version tokens and
an ordering question needs `<`/`>`. When either side does not denote an instant
the two are compared verbatim exactly as before, so only instant-bearing pairs
change verdict.
The pin drives a hand-made `Date` — `@objectstack/metadata-protocol` has no
driver dependency and must not grow one — over four consecutive days, the
smallest fixture for which no timezone alignment can make the old weekday
comparison agree with chronology.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
* chore(gates): re-point the isSystem census anchor and register the new engine double
Both are the gates' own sanctioned repairs for the line/ledger movement the fix
caused, applied with their own tooling and inspected:
- `check-system-context-census --fix` RE-POINTED row 21's anchor
`metadata-protocol/src/protocol.ts:1664` -> `:1736`, the 72-line shift the new
`compareAuditInstants` helper block introduced above it. No row was deleted and
no needle changed; the gate then reports 109 elevation read sites, 145 anchors
resolving.
- `check-engine-double-contract --write` ADDED one row recording that the new pin
file pins 1 `findOne` double ("1 added or grown, 0 lost"). The shrink-only
baseline is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
* chore(docs): re-derive the isSystem census after merging origin/main
The merge of origin/main routed content/docs/permissions/system-context.mdx
through the os-regen driver, which exits 0 without text-merging and leaves
git's pre-filled OURS side in place. That silently dropped the 16 anchor
re-points main had landed (objectstack-ai#13829, objectstack-ai#13934, objectstack-ai#13910, objectstack-ai#13857) while keeping this
branch's single re-point.
This commit takes main's side of the page and re-derives every anchor from the
merged tree with `pnpm gen:system-context-census`, which re-pointed row 21's
metadata-protocol/src/protocol.ts anchor to 1736. Prose is byte-identical on
both sides once line numbers are normalised, so nothing but line numbers moved.
---------
Co-authored-by: Claude <noreply@anthropic.com>
os-sales pushed a commit that referenced this pull request Sep 2, 2026
…ly at mint
`ShareLinkService.resolveToken()` now reads the object's CURRENT
`publicSharing` block on every redemption and refuses when `enabled` is
not `true` — before the record probe, before the usage stamp, before any
sibling key inside the block is evaluated. Re-enabling the block restores
the same tokens (a standing policy, not a revocation). How a link was
minted buys it nothing at redemption: rows minted under a system context
or the `permissive` bypass on a switched-off object refuse the same way,
and an object with no block at all is the same switch at its default.
The refusal reuses the undifferentiated `null` a revoked / expired /
unknown / ineligible token already gets — no new error code, no new
response branch, no usage stamp (over HTTP the generic 404). The readable
reason goes to the server-side log at `warn`, where the eligibility
refusal already writes its own.
Tests: the standing-policy pins (fake engine and real driver), the
HTTP-seam shape, the log line, the bypass-minted path, and the reversal
register for the #13856 pins that asserted serving on a disabled block.
Changeset marked as a breaking runtime change in PR #13857's form.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sharing: publicSharing.eligibility is evaluated only at mint — a link keeps serving a record after it stops being eligible

3 participants

@os-warren@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

Hold publicSharing.eligibility at redemption, not only at mint - #13857

Merged
os-steve merged 4 commits into
mainfrom
claude/issue-13608-eligibility-at-redemption
Sep 1, 2026
Merged

Hold publicSharing.eligibility at redemption, not only at mint#13857
os-steve merged 4 commits into
mainfrom
claude/issue-13608-eligibility-at-redemption

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes#13608

publicSharing.eligibility was enforced at mint (createLinkassertEligible) and never again. resolveToken() checked revoked_at, expires_at, the audience gates, the password and record EXISTENCE — then served whatever survived, under SYSTEM_CTX, to a caller with no principal. Publish an article published + public, mint a link, flip audience to internal or status back to draft, and the old token kept resolving and kept serving the record in full. Declared ≠ enforced.

Triage's strongest argument is carried into the code comment: in that same door, recordStillExists is deliberately fail-CLOSED, so a deleted record stopped being served immediately while a reclassified one did not — two failure directions in one door, and the governed side wins.

Premise re-verified against packages/plugins/plugin-sharing/src/ on the base (the card measured the installed dist/index.js): located by symbol — createLink and assertEligible at the mint seam, resolveToken / getPolicy / recordStillExists at the serving seam. The card's reading holds on source, verbatim.

The four ruled items (maintainer ruling 2026-08-31, comment 5478574829)

1. Re-evaluate in resolveToken() before serving, fail-closed. The gate calls assertEligible itself — the same function the mint path calls — so mint and redemption cannot drift on strictness (true, never merely truthy), on the declared-field binding, or on which faults refuse. It is ONE read either way: when a predicate is declared, the existence probe's projection widens from ['id'] to the whole row instead of a second query being issued (loadRecordForServing(object, recordId, withRecord)), so an object with no eligibility key keeps the exact probe it always had — pinned in both directions. A policy tightening cutting off already-minted links is recorded as a behaviour change in the changeset and in migration-facing wording (item 3).

2. The refusal reuses the existing branch — measured at both seams.stillEligible catches assertEligible's throw and returns false; resolveToken answers null, the same value revoked, expired and record-gone already return. No new error code (the ADR-0112 ledger is untouched), no new response branch, no usage stamp, and nothing thrown that a route could turn into a 422. See the measurement section below for what an anonymous caller actually observes.

3. The documentation half ships here. Stated beside the declaration key (packages/spec/src/data/object.zod.ts — the eligibility TSDoc and its .describe(), which projects into content/docs/references/data/object.mdx), on the contract (IShareLinkService.resolveToken plus design note 6), and in the hand-written sharing security page with an upgrade callout (content/docs/protocol/objectql/security.mdx).

4. Option B is not here, and neither is the scope ban's subject. No write-time revocation, no audit trail, no hook mechanism; the diff adds no hook surface and touches nothing about hooks on platform objects an app declares a dependency on. The whole diff is 7 files: the service, its test, two spec docs sites, two docs pages, one changeset.

The refusal-shape measurement — and one honest reading

At the service seam, five arms were driven in one live backend and compared by IDENTITY, not by shape: reclassified · revoked · expired · record-gone · unknown token. All five return the same value, null. Nothing on the wire distinguishes them.

At the HTTP seam the real route was mounted on the real service and driven anonymously (the route's secure default context):

casestatuscode
reclassified after mint404INVALID_OR_EXPIRED
token that never existed404INVALID_OR_EXPIRED
revoked link410EXPIRED_OR_REVOKED

The reclassified answer is asserted deep-equal to the unknown-token answer, and asserted not to contain the words eligib or audience.

⚠️ Read this precisely, because it is not word-for-word "the same response as a revoked link": the reclassified link lands in the route's generic invalid / expired / revoked bucket, byte-identical to a token that never existed, and NOT in the 410 branch. That is forced by ruling item 2 itself — the route picks 410 off revoked_at / expires_at on the row, so reaching it would require the service to hand the route a distinguishable "ineligible" answer, which is exactly what item 2 forbids. And it is the strictly LESS informative of the two buckets: 410 would confirm to a holder that the token was real. The 410-vs-404 split between revoked and unknown is pre-existing, untouched here, and recorded in the test as measured rather than assumed.

Verification

Everything below was run on 926c1af6d (branch head), in a dedicated worktree, heavy runs serialized through scripts/pm/os-verify-lock.sh.

  • pnpm --filter @objectstack/plugin-sharing test30 files, 699 tests passed.
  • pnpm --filter @objectstack/plugin-sharing typecheck and pnpm --filter @objectstack/spec exec tsc --noEmit — clean.
    ⚠️ The package's tsconfig.jsonexcludes **/*.test.ts, so that typecheck says nothing about the new test code. Measured separately by reproducing the TEST_DEBT program (package tsconfig minus the test exclusion, dependency closure built): exactly 3 errors, the same TS6133 ×2 + TS18048 ×1 the ledger freezes, zero of them in the edited file — the ratchet does not move.
  • pnpm lint (eslint . --no-inline-config, whole repo) — clean.
  • Gate family re-derived from the ACTUAL diff (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack), both sections read. All ran green, including: check:authorable-surface, check:generated (14/14 artifacts current), check:docs, check:doc-authoring, check:yaml-examples, check:llms-txt, check:skill-refs, check:skill-examples, check:variant-docs, check:liveness, check:empty-state, check:strictness-ledger, check:i18n (9 packages in sync), check:i18n-stale-fill, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:cross-package-test-inputs, check:test-source-alias, check:type-check-coverage, check:system-context-census, check:nul-bytes, check:empty-changeset, check-adr-0087-registration, check-changeset-no-major, the doc gate family and the repo-root node gates.
  • Two gates needed their build prerequisite cleared first, and were then measured green rather than left as NOT MEASURED: check:i18n (the CLI + extract closure) and check:skill-examples (@objectstack/client-react declarations).
  • node scripts/check-test-completeness.mjs exits 3 = PREREQUISITE NOT MET locally (it parses a saved turbo run test log, which a local family run has none of) — recorded as NOT MEASURED, not as a pass.
  • Two generated artifacts moved with the source and are committed: content/docs/references/data/object.mdx (gen:schema then gen:docs) and the five re-anchored citations in content/docs/permissions/system-context.mdx (check-system-context-census --fix, pure line rot from this diff's line shifts).

Ablation

Predicted before the run: the eleven redemption pins redden; every mint-side pin and every "nothing else moved" control stays green.

The repair was committed first; the mutation was made in the working tree only, with a trap … EXIT INT TERM restoring by absolute path, and it was proved on disk before measuring — not by an editor's exit code:

[pre] HEAD blob = 3909d07fea4fd41585bf158319eaa97d76415b1a (working tree matches)
[mutate] two anchored replacements applied (0 hits would have aborted)
[post] mutated blob = 2e223b13668ed8aa6391f046ba1fada6580ccf1f
[markers] removed-call-sites=0 (want 0) · injected-markers=2 (want 2) · widening-sites=0 (want 0)
[vitest] exit=1
Test Files 1 failed (1)
Tests 11 failed | 22 passed (33)

The eleven that reddened:

× `audience` flipped to internal: the same token resolves, then does not
× `status` reverted to draft: the same token resolves, then does not
× at the service seam it is `null` — the identical value, not a lookalike
× at the HTTP seam an anonymous caller cannot tell it from an unknown token
× the ineligible arm returns — it never throws a code an anonymous caller could read
× a refused redemption stamps no usage — exactly like a revoked one
× a predicate that no longer compiles refuses a record that still qualifies
× a predicate naming an UNDECLARED key refuses as a fault, not as a verdict
× a predicate resolving to a non-boolean has not consented
× the refusal reason names the two things an operator needs, and stays server-side
× the eligibility read WIDENS the same probe rather than adding a second query

Restore proved by state, not by the trap firing: git status clean, git diff HEAD empty, git hash-object back to the HEAD blob 3909d07f…, zero ablation markers left; the file re-ran 33/33 green afterwards.

⚠️ The first ablation run reddened only TEN, and that is the run worth reporting: "at the service seam it is null" passed with the gate fully removed, because it deleted the reclassified record to exercise the #5190 arm — so the existence probe answered null for the reclassified token too, and the assertion held for a reason that has nothing to do with eligibility. A green-in-both-directions case is a declared control, never ablation evidence. It was repaired (the record-gone arm now has its own record; the reclassified one stays in the table) and the pin then reddened. The gate's own arms are still measured as controls: the two #5190 deleted-record cases, the still-eligible resolve with its redactFields set unchanged, the no-predicate object, and the id-only projection all stay green under ablation.

⛔ Clause ② — declared, not self-cleared

This diff hits the clause-② path limb: packages/spec/src/contracts/share-link-service.ts and packages/spec/src/data/object.zod.ts (both under packages/spec/src/**, named by the re-derivation as the suspect surface). No accept-set moves and no key is added, renamed or removed — the spec change is documentation of semantics the runtime now holds — but that judgement is not mine to make. Handed back to the PM: this may not enqueue until the contract review clears it.

Out of scope, filed

Generated by Claude Code


Generated by Claude Code

…demption
`ShareLinkService.resolveToken()` now re-evaluates the object's declared
`publicSharing.eligibility` predicate against the record it is about to
serve, through the same `assertEligible` the mint path calls. A record
reclassified out of the policy after a link was minted stops being served
through that link; an unevaluable predicate refuses (fail-closed), matching
mint.
The refusal reuses the undifferentiated `null` a revoked, expired or unknown
token already gets — no new error code, no new response branch, no usage
stamp — because distinguishing those cases for a caller with no principal is
an existence oracle. The readable reason goes to the server-side log.
Docs: the semantics are now stated beside the `publicSharing.eligibility`
declaration key, on `IShareLinkService.resolveToken`, and in the hand-written
sharing security page with an upgrade note.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…string
`check:doc-authoring` refuses issue-id citations in customer-facing strings
(maintainer ruling 2026-08-12) — `.describe()` prose projects into
content/docs/references/** and the generated skill artifacts, where `#NNNN`
resolves to nothing. The sentence is rephrased rather than truncated; the
reference stays in the adjacent TSDoc, which only an internal reader sees.
Also re-anchors the five system-context census citations this branch's line
shifts moved (`check:system-context-census --fix`).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
The first ablation run measured this case GREEN with the redemption gate
removed: it deleted the reclassified record to exercise the #5190 arm, so the
record-existence probe answered `null` for the reclassified token too and the
assertion held for a reason that has nothing to do with eligibility. The
record-gone arm now gets its own record, and the reclassified one stays in the
table — ineligible, not gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-sharing, @objectstack/spec, touching 7 documentable anchor(s).

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

  • content/docs/protocol/objectql/security.mdx(via publicSharing (symbol, a field of const object ObjectSchemaBase))
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 — 127 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 f532630d0246e93b36f869579eb5fa4184d51141packageMentionDocs.

Which tree this was computed on

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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f532630d0246e93b36f869579eb5fa4184d51141 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-warrenClaude

Copy link
Copy Markdown
Collaborator

Contract review (Clause ②) — PASS

Reviewed at head 926c1af6d7bd2bb19195aeffc8354e4860ef8c77, still the head. Rendered by a CONTRACT_REVIEW_TIER reviewer in an isolated context; transcript tier-verified before adoption (36 harness-stamped assistant turns, 100% at tier, first and last included, service_tier: standard throughout, zero fallback evidence). The triage seat runs below tier and therefore adopts verbatim or voids whole — no rewriting, trimming or softening. Adopted verbatim, unedited:

VERDICT: PASS
CLAUSE-2-PATH: yes
CLAUSE-2-CONTENT: no
DECLARATION-HONEST: yes
ONE-LINE: PASS — the spec half of the diff moves no accept-set and widens no surface (its one normative addition transcribes maintainer ruling item 2 onto an already-published, unchanged SPI shape), so only the path limb fires and every factual clause in the PR's declaration checked out true against the actual diff; the needs:contract-review label may be cleared.
FINDINGS:
- Path limb fires on exactly two files: "packages/spec/src/contracts/share-link-service.ts" and "packages/spec/src/data/object.zod.ts", both under packages/spec/src/**, out of 8 changed files enumerated from the API.
- Zod accept-set is unmoved: the changed line still reads "eligibility: z.string().optional().describe(" on both sides; the diff around it is TSDoc and describe-string only, "redactFields: z.array(z.string()).optional()" and the closing "}).optional().describe('Public share-link policy" are untouched, so the strictObject accepted key set is byte-identical.
- Content limb argued NO, not asserted: IShareLinkService IS published — package.json exports has a "./contracts" subpath and the barrel carries "export * from './share-link-service.js';" — so the new "Implementations MUST NOT return a distinguishable answer per reason, and MUST NOT throw one either" is published normative text; but it adds no method, key, or accepted input (it NARROWS conforming implementations), and it transcribes ruling item 2 verbatim ("⛔ 不新设「不再合格」的可区分应答", comment 5478574829) — the behaviour it makes normative is exactly the runtime security change the 2026-08-28 negative boundary assigns to the maintainer floor, whose control (the ruling) has been exercised; ruling item 3 ordered this documentation to ship with the card ("C 的文档半边必做,随本卡交付"). Same reasoning covers the .describe() rewrite and design note 6: they document WHEN an unchanged-shape key is enforced; the projected "content/docs/references/data/object.mdx" row changes text, not shape.
- Declaration honest, with one nuance: "This diff hits the clause-② path limb" — true; "No accept-set moves and no key is added, renamed or removed" — true against the patch; "the spec change is documentation of semantics the runtime now holds" — true but understated, since the resolveToken TSDoc is normative MUST-NOT language binding all implementers, not mere description; the body's own "that judgement is not mine to make" hands exactly that call here, so it is not a false claim.
- Machine spelling ABSENT from the claim comment (systemic gap, recorded not fixed): comment 5479312665 says "档位:**条款② 按实际 diff 在入队闸门判**(裁决原文如此)" — neither "Clause-②: yes" nor "Clause-②: no" appears verbatim, though SKILL.md requires "恰这两种拼写:`Clause-②: yes` / `Clause-②: no`" in the claim comment and ensure-pm-labels.sh confirms the gate reads "the card's claim comment". Control: "条款②" does hit in that comment. Enqueue remains blocked by the path limb regardless, so this does not change this card's gating outcome.
- Body's file count is off by one: "The whole diff is 7 files" but the API enumerates 8 — the uncounted file is "content/docs/permissions/system-context.mdx", which the body itself discloses elsewhere as "the five re-anchored citations in content/docs/permissions/system-context.mdx" (pure line-anchor rot, ":413" → ":423" etc.). Inaccurate arithmetic, not scope smuggling.
- Changeset honest: the file itself grades "@objectstack/plugin-sharing": minor and "@objectstack/spec": minor with a "**BREAKING** runtime behaviour change" banner — file matches body (unlike the sibling PR's patch-vs-minor mismatch), and "minor" is the repo's ceiling: scripts/check-changeset-no-major.mjs states "During the launch window we ship breaking changes as `minor`" and fails any major.
- ADR-0087 claim holds: "<!-- adr-0087: not-required (no-migration-prescription)" is present and its factual basis — "publicSharing.eligibility keeps its name, its type and its accept-set" — is confirmed by the zod patch; nothing authorable is removed, renamed, or re-shaped.
- Scope verified: no write-time revocation, no audit-trail rows, no hook mechanism anywhere in the 8-file diff (the only new observability is the server-side "this.logger?.warn?.('[share-link] redemption refused" line, which ruling item 2 itself directs to the log); nothing under content/docs/releases/ is touched (control: the 8-file enumeration is non-empty and fully listed).
- Head fresh and green: the branch tip is still 926c1af6d7bd2bb19195aeffc8354e4860ef8c77 (the brief's SHA), and all 36 check runs at that head are completed — 33 success, 3 skipped, none failed, none in_progress.

Carrier action

needs:contract-review is being cleared from both carriers in this same stroke — this PR and card #13608. Provenance: maintainer ruling 2026-08-25 ("审核通过你应该直接负责合并吧,还要等原始的项目经理吗"), under which a PASS clears the gate label rather than parking it for a second opinion.

⚠️The landing action is NOT mine and I am not taking it. Contract review belongs to the card's dispatching seat (domain:services here); the triage seat's standing round is explicitly "非放行必要条件,在线时作事后审计/抽查" and "⛔ 不与席内复核抢同一载体的放行动作". So this is an audit PASS: the gate is cleared, and the pre-landing checks → flip ready → enqueue sequence in landing-operations.md is the dispatching seat's to run. ⛔ Not flipping ready, not arming auto-merge, not approving — 席位永不代批 is unchanged.

Two record defects worth fixing while you are here — neither blocks

  1. The body says "The whole diff is 7 files"; it is 8. The uncounted one is content/docs/permissions/system-context.mdx, which the body itself discloses two sections later. Arithmetic, not smuggling — but the sentence is used as a scope guarantee, so it should be right.
  2. Card sharing: publicSharing.eligibility is evaluated only at mint — a link keeps serving a record after it stops being eligible #13608's claim comment carries no machine Clause-②: line. It reads "档位:条款② 按实际 diff 在入队闸门判(裁决原文如此)". The gate's declaration limb reads that comment and fixes exactly two spellings. Here the path limb fired and held the PR regardless, so nothing was lost — but on a PR touching no contract path, that comment is the only limb that can fire. Systemic; filed as The Clause-②: yes | no machine spelling is missing from the claim comment on 2 of 3 measured cards — the enqueue gate's predicate reads it there, and it is not there #13914.

Generated by Claude Code

@os-steveClaude

Copy link
Copy Markdown
Collaborator

契约复审:PASS —— 逐字采纳,标签已清,同笔落地

你在 PR 正文里把条款②路径肢声明而不自清(packages/spec/src/contracts/share-link-service.ts + packages/spec/src/data/object.zod.ts),交回 PM —— 这是对的做法。复审已执行完毕。

复审席资格,机读在案(⛔ 不是自述):

  • 降档保险丝:本席自会话 get_session 读回 last_served_modelCONTRACT_REVIEW_TIER自会话不得复审,改派达档子代理。
  • 转录核验(采信前必做):子代理 transcript 中 harness 逐消息盖章的 model 字段分布 ——
assistant 轮次: 89
盖章 "claude-fable-5": 89
非 fable 盖章(回退证据): 0

⇒ 产出裁决的每一轮都在档,零回退 ⇒ 裁决合法,按「逐字采纳或整体作废」二选一中的逐字采纳。下面是复审席的原文,⛔ 未改写、未删节、未润色:

CONTRACT REVIEW — PR #13857 @ 926c1af6d
VERDICT: PASS
INCREMENT REVIEWED: packages/spec/src/contracts/share-link-service.ts, packages/spec/src/data/object.zod.ts (diffed 16c3601d2 [merge-base with origin/main] → 926c1af6d)
FINDINGS:
none (one scoped observation, not a defect, recorded in BASIS item 4)
BASIS:
1. Schema shape — UNCHANGED. Comment-stripped code diff of both files between merge-base and head:
contracts/share-link-service.ts is byte-identical (JSDoc-only change); object.zod.ts differs by
exactly one string literal — the `.describe()` on `publicSharing.eligibility`. The zod chain is
`z.string().optional()` on both sides; no key added/removed/renamed, no optionality flipped, no
accept-set moved. Machine-confirmed: `check:spec-changes` green at head with `spec-changes.json`
untouched by the PR (0 baseline files in the diff).
2. Published surface — NOT WIDENED. Built `packages/spec` dist at head and ran `check:api-surface`:
"public API surface + factory signatures unchanged ✓". No new exports; `IShareLinkService`,
`resolveToken` signature and `ResolveShareLinkResult` are byte-identical in code.
3. Prose vs runtime — TRUE, verified against packages/plugins/plugin-sharing/src/share-link-service.ts
at head and executable-confirmed by its test suite (share-link-eligibility.test.ts: 33/33 pass):
- mint: `assertEligible` called in `createLink` before the insert → 422, no link row (pinned:
"a DRAFT record is refused with 422 and mints nothing");
- redemption: every `resolveToken` re-evaluates via `stillEligible`, which calls the SAME
`assertEligible` — one implementation at both seams, as the header note claims (pinned:
reclassified record "resolves, then does not");
- fail-closed at both points: compile failure, eval fault, and non-strict-`true` verdict all
refuse (three ELIGIBILITY_UNEVALUABLE / RECORD_NOT_ELIGIBLE arms in `assertEligible`; pinned
at both seams);
- undifferentiated `null`: identical value to revoked/expired/unknown, never a thrown readable
code, HTTP-seam indistinguishable, reason goes to `logger.warn` only (all four pinned);
- "use_count/last_used_at on SUCCESSFUL resolution only": stamp sits after every gate,
immediately before the successful return (pinned: "a refused redemption stamps no usage");
- null-on: missing/short token, revoked, expired, audience, password, record-gone (#5190),
ineligible (#13608) — every listed arm present in `resolveToken`, each `return null`;
- "Evaluator is the same one used by sharing rules": both consume the canonical CEL front end
in @objectstack/formula (`parseCelToAstWithReason`, convergence #6132; sharing rules lower
the same parsed AST via `compileCelToFilter`);
- prose narrowing "CEL/JSONLogic" → "CEL" matches the runtime (dialect hardcoded `'cel'`).
4. Commitments — nothing promised beyond what ships; the fail-closed and both-points claims are
implemented and pinned. Scoped observation (not a defect): enforcement is governed by the parent
`publicSharing.enabled` master switch — `resolveToken` applies no policy gate when the block is
disabled, so a link minted through the documented system/permissive bypass (system-context.mdx
ledger row 37, refreshed in this PR), or minted while enabled and later orphaned by the author
disabling the whole block, redeems without the eligibility re-check. This is pre-existing
resolveToken structure, uniform across all sibling policy keys (`redactFields` likewise), not
introduced or altered by this increment, and the key's describe — like its siblings' — speaks
within an in-force policy.
5. Generated artifacts — PROPAGATED AND CONSISTENT. Full `check:generated` aggregate run at head in
a scratch worktree (after `gen:schema` + package build, both environment prerequisites, not
staleness): all 14 artifacts up to date, including `check:docs` ("230 generated files in sync",
exit 0). Repo-wide grep at head finds the eligibility describe in exactly two committed files:
the zod source and the regenerated `content/docs/references/data/object.mdx`, whose table row
matches the new `.describe()` verbatim. The build-time json-schema output (gitignored) emits the
new description verbatim at `properties.publicSharing.properties.eligibility.description`;
`json-schema.manifest/` is a name-set ratchet carrying no description text, correctly unmoved.
No generated page consumes the contracts JSDoc (it ships via built .d.ts hover text and the npm
tarball source only).

PM 侧收口

  • needs:contract-review 已清(PASS 才清标;FAIL 是 label-flip 交接,标不清)。
  • 落地前检,按最终文件清单当场重跑、⛔ 非回忆:node scripts/pm/check-governed-merges.mjs --test × 8 条路径 ⇒ 0 of 8 path(s) hit the register非受管面,走普通队列⚠️ 这条必须当场跑:packages/spec/src/** 在面上时最容易凭印象判成受管。
  • 全 check 绿(42 个 check run,无 failure),mergeable_state: clean,head 926c1af6d 与复审席核的是同一个 sha
  • ⇒ 依「清标即落地」(维护者 2026-08-25),转 ready + 挂 auto-merge,与清标同一笔。

BASIS 4 的那条 scoped observation 不随本 PR 处置,但它不是没人管:publicSharing.enabled 关掉后 resolveToken 不再有任何策略门这件事,正是你自己在「Out of scope, filed」里立的 #13856(p1,security,domain:services 队列里)。复审席独立地从契约面又撞到同一处 ⇒ 两条独立证据指向同一张卡,该卡的定级站得住。


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 00:02
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit fc9ba76Sep 1, 2026
47 checks passed
@os-steve
os-steve deleted the claude/issue-13608-eligibility-at-redemption branch September 1, 2026 00:38
zhuangjianguo pushed a commit that referenced this pull request Sep 1, 2026
The merge of origin/main routed content/docs/permissions/system-context.mdx
through the os-regen driver, which exits 0 without text-merging and leaves
git's pre-filled OURS side in place. That silently dropped the 16 anchor
re-points main had landed (#13829, #13934, #13910, #13857) while keeping this
branch's single re-point.
This commit takes main's side of the page and re-derives every anchor from the
merged tree with `pnpm gen:system-context-census`, which re-pointed row 21's
metadata-protocol/src/protocol.ts anchor to 1736. Prose is byte-identical on
both sides once line numbers are normalised, so nothing but line numbers moved.
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 1, 2026
…, so `rollbackToPackageCommit` stops planning off the weekday name (objectstack-ai#14036)
* fix(metadata-protocol): order the ADR-0067 commit timeline by instant, not by the weekday name
`created_at` is an engine-injected audit column: not in `datetimeFields`, and
`SqlDriver#formatOutput` repairs it only inside `if (this.isSqlite)`. The live
SQL dialects therefore hand it out of the record read door as a JS `Date` while
the SQLite family hands out canonical ISO-Z text.
Both ADR-0067 commit-timeline consumers compared `String(created_at)`, and
`String(aDate)` is `"Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)"` —
the LEADING token is the weekday NAME, so lexicographic order over those strings
is `Fri < Mon < Sat < Sun < Thu < Tue < Wed`. Unrelated to chronology, and
stable across the whole set, so it is wrong on every run and wrong the same way.
- `listCommits` returned the timeline in weekday-name order while claiming
newest-first; its own comment stated the assumption ("sort by the ISO
timestamp") and it was false on the production default driver.
- `rollbackToPackageCommit` both consumed that ordering and re-derived the same
comparison itself, so neither site could correct the other: it reverted
`apply` commits OLDER than the target and skipped the newer ones it exists to
undo.
Both sites now compare canonical absolute instants through `compareAuditInstants`,
a sibling of the `canonicalVersionInstant` helper objectstack-ai#13382 landed one seam over in
this same file. The canonicalisation is reused; the ordering is new, because
`versionTokensAgree` answers equality between client-supplied version tokens and
an ordering question needs `<`/`>`. When either side does not denote an instant
the two are compared verbatim exactly as before, so only instant-bearing pairs
change verdict.
The pin drives a hand-made `Date` — `@objectstack/metadata-protocol` has no
driver dependency and must not grow one — over four consecutive days, the
smallest fixture for which no timezone alignment can make the old weekday
comparison agree with chronology.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
* chore(gates): re-point the isSystem census anchor and register the new engine double
Both are the gates' own sanctioned repairs for the line/ledger movement the fix
caused, applied with their own tooling and inspected:
- `check-system-context-census --fix` RE-POINTED row 21's anchor
`metadata-protocol/src/protocol.ts:1664` -> `:1736`, the 72-line shift the new
`compareAuditInstants` helper block introduced above it. No row was deleted and
no needle changed; the gate then reports 109 elevation read sites, 145 anchors
resolving.
- `check-engine-double-contract --write` ADDED one row recording that the new pin
file pins 1 `findOne` double ("1 added or grown, 0 lost"). The shrink-only
baseline is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
* chore(docs): re-derive the isSystem census after merging origin/main
The merge of origin/main routed content/docs/permissions/system-context.mdx
through the os-regen driver, which exits 0 without text-merging and leaves
git's pre-filled OURS side in place. That silently dropped the 16 anchor
re-points main had landed (objectstack-ai#13829, objectstack-ai#13934, objectstack-ai#13910, objectstack-ai#13857) while keeping this
branch's single re-point.
This commit takes main's side of the page and re-derives every anchor from the
merged tree with `pnpm gen:system-context-census`, which re-pointed row 21's
metadata-protocol/src/protocol.ts anchor to 1736. Prose is byte-identical on
both sides once line numbers are normalised, so nothing but line numbers moved.
---------
Co-authored-by: Claude <noreply@anthropic.com>
os-sales pushed a commit that referenced this pull request Sep 2, 2026
…ly at mint
`ShareLinkService.resolveToken()` now reads the object's CURRENT
`publicSharing` block on every redemption and refuses when `enabled` is
not `true` — before the record probe, before the usage stamp, before any
sibling key inside the block is evaluated. Re-enabling the block restores
the same tokens (a standing policy, not a revocation). How a link was
minted buys it nothing at redemption: rows minted under a system context
or the `permissive` bypass on a switched-off object refuse the same way,
and an object with no block at all is the same switch at its default.
The refusal reuses the undifferentiated `null` a revoked / expired /
unknown / ineligible token already gets — no new error code, no new
response branch, no usage stamp (over HTTP the generic 404). The readable
reason goes to the server-side log at `warn`, where the eligibility
refusal already writes its own.
Tests: the standing-policy pins (fake engine and real driver), the
HTTP-seam shape, the log line, the bypass-minted path, and the reversal
register for the #13856 pins that asserted serving on a disabled block.
Changeset marked as a breaking runtime change in PR #13857's form.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sharing: publicSharing.eligibility is evaluated only at mint — a link keeps serving a record after it stops being eligible

3 participants

@os-warren@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

Hold publicSharing.eligibility at redemption, not only at mint - #13857

Merged
os-steve merged 4 commits into
mainfrom
claude/issue-13608-eligibility-at-redemption
Sep 1, 2026
Merged

Hold publicSharing.eligibility at redemption, not only at mint#13857
os-steve merged 4 commits into
mainfrom
claude/issue-13608-eligibility-at-redemption

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes#13608

publicSharing.eligibility was enforced at mint (createLinkassertEligible) and never again. resolveToken() checked revoked_at, expires_at, the audience gates, the password and record EXISTENCE — then served whatever survived, under SYSTEM_CTX, to a caller with no principal. Publish an article published + public, mint a link, flip audience to internal or status back to draft, and the old token kept resolving and kept serving the record in full. Declared ≠ enforced.

Triage's strongest argument is carried into the code comment: in that same door, recordStillExists is deliberately fail-CLOSED, so a deleted record stopped being served immediately while a reclassified one did not — two failure directions in one door, and the governed side wins.

Premise re-verified against packages/plugins/plugin-sharing/src/ on the base (the card measured the installed dist/index.js): located by symbol — createLink and assertEligible at the mint seam, resolveToken / getPolicy / recordStillExists at the serving seam. The card's reading holds on source, verbatim.

The four ruled items (maintainer ruling 2026-08-31, comment 5478574829)

1. Re-evaluate in resolveToken() before serving, fail-closed. The gate calls assertEligible itself — the same function the mint path calls — so mint and redemption cannot drift on strictness (true, never merely truthy), on the declared-field binding, or on which faults refuse. It is ONE read either way: when a predicate is declared, the existence probe's projection widens from ['id'] to the whole row instead of a second query being issued (loadRecordForServing(object, recordId, withRecord)), so an object with no eligibility key keeps the exact probe it always had — pinned in both directions. A policy tightening cutting off already-minted links is recorded as a behaviour change in the changeset and in migration-facing wording (item 3).

2. The refusal reuses the existing branch — measured at both seams.stillEligible catches assertEligible's throw and returns false; resolveToken answers null, the same value revoked, expired and record-gone already return. No new error code (the ADR-0112 ledger is untouched), no new response branch, no usage stamp, and nothing thrown that a route could turn into a 422. See the measurement section below for what an anonymous caller actually observes.

3. The documentation half ships here. Stated beside the declaration key (packages/spec/src/data/object.zod.ts — the eligibility TSDoc and its .describe(), which projects into content/docs/references/data/object.mdx), on the contract (IShareLinkService.resolveToken plus design note 6), and in the hand-written sharing security page with an upgrade callout (content/docs/protocol/objectql/security.mdx).

4. Option B is not here, and neither is the scope ban's subject. No write-time revocation, no audit trail, no hook mechanism; the diff adds no hook surface and touches nothing about hooks on platform objects an app declares a dependency on. The whole diff is 7 files: the service, its test, two spec docs sites, two docs pages, one changeset.

The refusal-shape measurement — and one honest reading

At the service seam, five arms were driven in one live backend and compared by IDENTITY, not by shape: reclassified · revoked · expired · record-gone · unknown token. All five return the same value, null. Nothing on the wire distinguishes them.

At the HTTP seam the real route was mounted on the real service and driven anonymously (the route's secure default context):

casestatuscode
reclassified after mint404INVALID_OR_EXPIRED
token that never existed404INVALID_OR_EXPIRED
revoked link410EXPIRED_OR_REVOKED

The reclassified answer is asserted deep-equal to the unknown-token answer, and asserted not to contain the words eligib or audience.

⚠️ Read this precisely, because it is not word-for-word "the same response as a revoked link": the reclassified link lands in the route's generic invalid / expired / revoked bucket, byte-identical to a token that never existed, and NOT in the 410 branch. That is forced by ruling item 2 itself — the route picks 410 off revoked_at / expires_at on the row, so reaching it would require the service to hand the route a distinguishable "ineligible" answer, which is exactly what item 2 forbids. And it is the strictly LESS informative of the two buckets: 410 would confirm to a holder that the token was real. The 410-vs-404 split between revoked and unknown is pre-existing, untouched here, and recorded in the test as measured rather than assumed.

Verification

Everything below was run on 926c1af6d (branch head), in a dedicated worktree, heavy runs serialized through scripts/pm/os-verify-lock.sh.

  • pnpm --filter @objectstack/plugin-sharing test30 files, 699 tests passed.
  • pnpm --filter @objectstack/plugin-sharing typecheck and pnpm --filter @objectstack/spec exec tsc --noEmit — clean.
    ⚠️ The package's tsconfig.jsonexcludes **/*.test.ts, so that typecheck says nothing about the new test code. Measured separately by reproducing the TEST_DEBT program (package tsconfig minus the test exclusion, dependency closure built): exactly 3 errors, the same TS6133 ×2 + TS18048 ×1 the ledger freezes, zero of them in the edited file — the ratchet does not move.
  • pnpm lint (eslint . --no-inline-config, whole repo) — clean.
  • Gate family re-derived from the ACTUAL diff (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack), both sections read. All ran green, including: check:authorable-surface, check:generated (14/14 artifacts current), check:docs, check:doc-authoring, check:yaml-examples, check:llms-txt, check:skill-refs, check:skill-examples, check:variant-docs, check:liveness, check:empty-state, check:strictness-ledger, check:i18n (9 packages in sync), check:i18n-stale-fill, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:cross-package-test-inputs, check:test-source-alias, check:type-check-coverage, check:system-context-census, check:nul-bytes, check:empty-changeset, check-adr-0087-registration, check-changeset-no-major, the doc gate family and the repo-root node gates.
  • Two gates needed their build prerequisite cleared first, and were then measured green rather than left as NOT MEASURED: check:i18n (the CLI + extract closure) and check:skill-examples (@objectstack/client-react declarations).
  • node scripts/check-test-completeness.mjs exits 3 = PREREQUISITE NOT MET locally (it parses a saved turbo run test log, which a local family run has none of) — recorded as NOT MEASURED, not as a pass.
  • Two generated artifacts moved with the source and are committed: content/docs/references/data/object.mdx (gen:schema then gen:docs) and the five re-anchored citations in content/docs/permissions/system-context.mdx (check-system-context-census --fix, pure line rot from this diff's line shifts).

Ablation

Predicted before the run: the eleven redemption pins redden; every mint-side pin and every "nothing else moved" control stays green.

The repair was committed first; the mutation was made in the working tree only, with a trap … EXIT INT TERM restoring by absolute path, and it was proved on disk before measuring — not by an editor's exit code:

[pre] HEAD blob = 3909d07fea4fd41585bf158319eaa97d76415b1a (working tree matches)
[mutate] two anchored replacements applied (0 hits would have aborted)
[post] mutated blob = 2e223b13668ed8aa6391f046ba1fada6580ccf1f
[markers] removed-call-sites=0 (want 0) · injected-markers=2 (want 2) · widening-sites=0 (want 0)
[vitest] exit=1
Test Files 1 failed (1)
Tests 11 failed | 22 passed (33)

The eleven that reddened:

× `audience` flipped to internal: the same token resolves, then does not
× `status` reverted to draft: the same token resolves, then does not
× at the service seam it is `null` — the identical value, not a lookalike
× at the HTTP seam an anonymous caller cannot tell it from an unknown token
× the ineligible arm returns — it never throws a code an anonymous caller could read
× a refused redemption stamps no usage — exactly like a revoked one
× a predicate that no longer compiles refuses a record that still qualifies
× a predicate naming an UNDECLARED key refuses as a fault, not as a verdict
× a predicate resolving to a non-boolean has not consented
× the refusal reason names the two things an operator needs, and stays server-side
× the eligibility read WIDENS the same probe rather than adding a second query

Restore proved by state, not by the trap firing: git status clean, git diff HEAD empty, git hash-object back to the HEAD blob 3909d07f…, zero ablation markers left; the file re-ran 33/33 green afterwards.

⚠️ The first ablation run reddened only TEN, and that is the run worth reporting: "at the service seam it is null" passed with the gate fully removed, because it deleted the reclassified record to exercise the #5190 arm — so the existence probe answered null for the reclassified token too, and the assertion held for a reason that has nothing to do with eligibility. A green-in-both-directions case is a declared control, never ablation evidence. It was repaired (the record-gone arm now has its own record; the reclassified one stays in the table) and the pin then reddened. The gate's own arms are still measured as controls: the two #5190 deleted-record cases, the still-eligible resolve with its redactFields set unchanged, the no-predicate object, and the id-only projection all stay green under ablation.

⛔ Clause ② — declared, not self-cleared

This diff hits the clause-② path limb: packages/spec/src/contracts/share-link-service.ts and packages/spec/src/data/object.zod.ts (both under packages/spec/src/**, named by the re-derivation as the suspect surface). No accept-set moves and no key is added, renamed or removed — the spec change is documentation of semantics the runtime now holds — but that judgement is not mine to make. Handed back to the PM: this may not enqueue until the contract review clears it.

Out of scope, filed

Generated by Claude Code


Generated by Claude Code

…demption
`ShareLinkService.resolveToken()` now re-evaluates the object's declared
`publicSharing.eligibility` predicate against the record it is about to
serve, through the same `assertEligible` the mint path calls. A record
reclassified out of the policy after a link was minted stops being served
through that link; an unevaluable predicate refuses (fail-closed), matching
mint.
The refusal reuses the undifferentiated `null` a revoked, expired or unknown
token already gets — no new error code, no new response branch, no usage
stamp — because distinguishing those cases for a caller with no principal is
an existence oracle. The readable reason goes to the server-side log.
Docs: the semantics are now stated beside the `publicSharing.eligibility`
declaration key, on `IShareLinkService.resolveToken`, and in the hand-written
sharing security page with an upgrade note.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…string
`check:doc-authoring` refuses issue-id citations in customer-facing strings
(maintainer ruling 2026-08-12) — `.describe()` prose projects into
content/docs/references/** and the generated skill artifacts, where `#NNNN`
resolves to nothing. The sentence is rephrased rather than truncated; the
reference stays in the adjacent TSDoc, which only an internal reader sees.
Also re-anchors the five system-context census citations this branch's line
shifts moved (`check:system-context-census --fix`).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
The first ablation run measured this case GREEN with the redemption gate
removed: it deleted the reclassified record to exercise the #5190 arm, so the
record-existence probe answered `null` for the reclassified token too and the
assertion held for a reason that has nothing to do with eligibility. The
record-gone arm now gets its own record, and the reclassified one stays in the
table — ineligible, not gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-sharing, @objectstack/spec, touching 7 documentable anchor(s).

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

  • content/docs/protocol/objectql/security.mdx(via publicSharing (symbol, a field of const object ObjectSchemaBase))
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 — 127 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 f532630d0246e93b36f869579eb5fa4184d51141packageMentionDocs.

Which tree this was computed on

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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f532630d0246e93b36f869579eb5fa4184d51141 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-warrenClaude

Copy link
Copy Markdown
Collaborator

Contract review (Clause ②) — PASS

Reviewed at head 926c1af6d7bd2bb19195aeffc8354e4860ef8c77, still the head. Rendered by a CONTRACT_REVIEW_TIER reviewer in an isolated context; transcript tier-verified before adoption (36 harness-stamped assistant turns, 100% at tier, first and last included, service_tier: standard throughout, zero fallback evidence). The triage seat runs below tier and therefore adopts verbatim or voids whole — no rewriting, trimming or softening. Adopted verbatim, unedited:

VERDICT: PASS
CLAUSE-2-PATH: yes
CLAUSE-2-CONTENT: no
DECLARATION-HONEST: yes
ONE-LINE: PASS — the spec half of the diff moves no accept-set and widens no surface (its one normative addition transcribes maintainer ruling item 2 onto an already-published, unchanged SPI shape), so only the path limb fires and every factual clause in the PR's declaration checked out true against the actual diff; the needs:contract-review label may be cleared.
FINDINGS:
- Path limb fires on exactly two files: "packages/spec/src/contracts/share-link-service.ts" and "packages/spec/src/data/object.zod.ts", both under packages/spec/src/**, out of 8 changed files enumerated from the API.
- Zod accept-set is unmoved: the changed line still reads "eligibility: z.string().optional().describe(" on both sides; the diff around it is TSDoc and describe-string only, "redactFields: z.array(z.string()).optional()" and the closing "}).optional().describe('Public share-link policy" are untouched, so the strictObject accepted key set is byte-identical.
- Content limb argued NO, not asserted: IShareLinkService IS published — package.json exports has a "./contracts" subpath and the barrel carries "export * from './share-link-service.js';" — so the new "Implementations MUST NOT return a distinguishable answer per reason, and MUST NOT throw one either" is published normative text; but it adds no method, key, or accepted input (it NARROWS conforming implementations), and it transcribes ruling item 2 verbatim ("⛔ 不新设「不再合格」的可区分应答", comment 5478574829) — the behaviour it makes normative is exactly the runtime security change the 2026-08-28 negative boundary assigns to the maintainer floor, whose control (the ruling) has been exercised; ruling item 3 ordered this documentation to ship with the card ("C 的文档半边必做,随本卡交付"). Same reasoning covers the .describe() rewrite and design note 6: they document WHEN an unchanged-shape key is enforced; the projected "content/docs/references/data/object.mdx" row changes text, not shape.
- Declaration honest, with one nuance: "This diff hits the clause-② path limb" — true; "No accept-set moves and no key is added, renamed or removed" — true against the patch; "the spec change is documentation of semantics the runtime now holds" — true but understated, since the resolveToken TSDoc is normative MUST-NOT language binding all implementers, not mere description; the body's own "that judgement is not mine to make" hands exactly that call here, so it is not a false claim.
- Machine spelling ABSENT from the claim comment (systemic gap, recorded not fixed): comment 5479312665 says "档位:**条款② 按实际 diff 在入队闸门判**(裁决原文如此)" — neither "Clause-②: yes" nor "Clause-②: no" appears verbatim, though SKILL.md requires "恰这两种拼写:`Clause-②: yes` / `Clause-②: no`" in the claim comment and ensure-pm-labels.sh confirms the gate reads "the card's claim comment". Control: "条款②" does hit in that comment. Enqueue remains blocked by the path limb regardless, so this does not change this card's gating outcome.
- Body's file count is off by one: "The whole diff is 7 files" but the API enumerates 8 — the uncounted file is "content/docs/permissions/system-context.mdx", which the body itself discloses elsewhere as "the five re-anchored citations in content/docs/permissions/system-context.mdx" (pure line-anchor rot, ":413" → ":423" etc.). Inaccurate arithmetic, not scope smuggling.
- Changeset honest: the file itself grades "@objectstack/plugin-sharing": minor and "@objectstack/spec": minor with a "**BREAKING** runtime behaviour change" banner — file matches body (unlike the sibling PR's patch-vs-minor mismatch), and "minor" is the repo's ceiling: scripts/check-changeset-no-major.mjs states "During the launch window we ship breaking changes as `minor`" and fails any major.
- ADR-0087 claim holds: "<!-- adr-0087: not-required (no-migration-prescription)" is present and its factual basis — "publicSharing.eligibility keeps its name, its type and its accept-set" — is confirmed by the zod patch; nothing authorable is removed, renamed, or re-shaped.
- Scope verified: no write-time revocation, no audit-trail rows, no hook mechanism anywhere in the 8-file diff (the only new observability is the server-side "this.logger?.warn?.('[share-link] redemption refused" line, which ruling item 2 itself directs to the log); nothing under content/docs/releases/ is touched (control: the 8-file enumeration is non-empty and fully listed).
- Head fresh and green: the branch tip is still 926c1af6d7bd2bb19195aeffc8354e4860ef8c77 (the brief's SHA), and all 36 check runs at that head are completed — 33 success, 3 skipped, none failed, none in_progress.

Carrier action

needs:contract-review is being cleared from both carriers in this same stroke — this PR and card #13608. Provenance: maintainer ruling 2026-08-25 ("审核通过你应该直接负责合并吧,还要等原始的项目经理吗"), under which a PASS clears the gate label rather than parking it for a second opinion.

⚠️The landing action is NOT mine and I am not taking it. Contract review belongs to the card's dispatching seat (domain:services here); the triage seat's standing round is explicitly "非放行必要条件,在线时作事后审计/抽查" and "⛔ 不与席内复核抢同一载体的放行动作". So this is an audit PASS: the gate is cleared, and the pre-landing checks → flip ready → enqueue sequence in landing-operations.md is the dispatching seat's to run. ⛔ Not flipping ready, not arming auto-merge, not approving — 席位永不代批 is unchanged.

Two record defects worth fixing while you are here — neither blocks

  1. The body says "The whole diff is 7 files"; it is 8. The uncounted one is content/docs/permissions/system-context.mdx, which the body itself discloses two sections later. Arithmetic, not smuggling — but the sentence is used as a scope guarantee, so it should be right.
  2. Card sharing: publicSharing.eligibility is evaluated only at mint — a link keeps serving a record after it stops being eligible #13608's claim comment carries no machine Clause-②: line. It reads "档位:条款② 按实际 diff 在入队闸门判(裁决原文如此)". The gate's declaration limb reads that comment and fixes exactly two spellings. Here the path limb fired and held the PR regardless, so nothing was lost — but on a PR touching no contract path, that comment is the only limb that can fire. Systemic; filed as The Clause-②: yes | no machine spelling is missing from the claim comment on 2 of 3 measured cards — the enqueue gate's predicate reads it there, and it is not there #13914.

Generated by Claude Code

@os-steveClaude

Copy link
Copy Markdown
Collaborator

契约复审:PASS —— 逐字采纳,标签已清,同笔落地

你在 PR 正文里把条款②路径肢声明而不自清(packages/spec/src/contracts/share-link-service.ts + packages/spec/src/data/object.zod.ts),交回 PM —— 这是对的做法。复审已执行完毕。

复审席资格,机读在案(⛔ 不是自述):

  • 降档保险丝:本席自会话 get_session 读回 last_served_modelCONTRACT_REVIEW_TIER自会话不得复审,改派达档子代理。
  • 转录核验(采信前必做):子代理 transcript 中 harness 逐消息盖章的 model 字段分布 ——
assistant 轮次: 89
盖章 "claude-fable-5": 89
非 fable 盖章(回退证据): 0

⇒ 产出裁决的每一轮都在档,零回退 ⇒ 裁决合法,按「逐字采纳或整体作废」二选一中的逐字采纳。下面是复审席的原文,⛔ 未改写、未删节、未润色:

CONTRACT REVIEW — PR #13857 @ 926c1af6d
VERDICT: PASS
INCREMENT REVIEWED: packages/spec/src/contracts/share-link-service.ts, packages/spec/src/data/object.zod.ts (diffed 16c3601d2 [merge-base with origin/main] → 926c1af6d)
FINDINGS:
none (one scoped observation, not a defect, recorded in BASIS item 4)
BASIS:
1. Schema shape — UNCHANGED. Comment-stripped code diff of both files between merge-base and head:
contracts/share-link-service.ts is byte-identical (JSDoc-only change); object.zod.ts differs by
exactly one string literal — the `.describe()` on `publicSharing.eligibility`. The zod chain is
`z.string().optional()` on both sides; no key added/removed/renamed, no optionality flipped, no
accept-set moved. Machine-confirmed: `check:spec-changes` green at head with `spec-changes.json`
untouched by the PR (0 baseline files in the diff).
2. Published surface — NOT WIDENED. Built `packages/spec` dist at head and ran `check:api-surface`:
"public API surface + factory signatures unchanged ✓". No new exports; `IShareLinkService`,
`resolveToken` signature and `ResolveShareLinkResult` are byte-identical in code.
3. Prose vs runtime — TRUE, verified against packages/plugins/plugin-sharing/src/share-link-service.ts
at head and executable-confirmed by its test suite (share-link-eligibility.test.ts: 33/33 pass):
- mint: `assertEligible` called in `createLink` before the insert → 422, no link row (pinned:
"a DRAFT record is refused with 422 and mints nothing");
- redemption: every `resolveToken` re-evaluates via `stillEligible`, which calls the SAME
`assertEligible` — one implementation at both seams, as the header note claims (pinned:
reclassified record "resolves, then does not");
- fail-closed at both points: compile failure, eval fault, and non-strict-`true` verdict all
refuse (three ELIGIBILITY_UNEVALUABLE / RECORD_NOT_ELIGIBLE arms in `assertEligible`; pinned
at both seams);
- undifferentiated `null`: identical value to revoked/expired/unknown, never a thrown readable
code, HTTP-seam indistinguishable, reason goes to `logger.warn` only (all four pinned);
- "use_count/last_used_at on SUCCESSFUL resolution only": stamp sits after every gate,
immediately before the successful return (pinned: "a refused redemption stamps no usage");
- null-on: missing/short token, revoked, expired, audience, password, record-gone (#5190),
ineligible (#13608) — every listed arm present in `resolveToken`, each `return null`;
- "Evaluator is the same one used by sharing rules": both consume the canonical CEL front end
in @objectstack/formula (`parseCelToAstWithReason`, convergence #6132; sharing rules lower
the same parsed AST via `compileCelToFilter`);
- prose narrowing "CEL/JSONLogic" → "CEL" matches the runtime (dialect hardcoded `'cel'`).
4. Commitments — nothing promised beyond what ships; the fail-closed and both-points claims are
implemented and pinned. Scoped observation (not a defect): enforcement is governed by the parent
`publicSharing.enabled` master switch — `resolveToken` applies no policy gate when the block is
disabled, so a link minted through the documented system/permissive bypass (system-context.mdx
ledger row 37, refreshed in this PR), or minted while enabled and later orphaned by the author
disabling the whole block, redeems without the eligibility re-check. This is pre-existing
resolveToken structure, uniform across all sibling policy keys (`redactFields` likewise), not
introduced or altered by this increment, and the key's describe — like its siblings' — speaks
within an in-force policy.
5. Generated artifacts — PROPAGATED AND CONSISTENT. Full `check:generated` aggregate run at head in
a scratch worktree (after `gen:schema` + package build, both environment prerequisites, not
staleness): all 14 artifacts up to date, including `check:docs` ("230 generated files in sync",
exit 0). Repo-wide grep at head finds the eligibility describe in exactly two committed files:
the zod source and the regenerated `content/docs/references/data/object.mdx`, whose table row
matches the new `.describe()` verbatim. The build-time json-schema output (gitignored) emits the
new description verbatim at `properties.publicSharing.properties.eligibility.description`;
`json-schema.manifest/` is a name-set ratchet carrying no description text, correctly unmoved.
No generated page consumes the contracts JSDoc (it ships via built .d.ts hover text and the npm
tarball source only).

PM 侧收口

  • needs:contract-review 已清(PASS 才清标;FAIL 是 label-flip 交接,标不清)。
  • 落地前检,按最终文件清单当场重跑、⛔ 非回忆:node scripts/pm/check-governed-merges.mjs --test × 8 条路径 ⇒ 0 of 8 path(s) hit the register非受管面,走普通队列⚠️ 这条必须当场跑:packages/spec/src/** 在面上时最容易凭印象判成受管。
  • 全 check 绿(42 个 check run,无 failure),mergeable_state: clean,head 926c1af6d 与复审席核的是同一个 sha
  • ⇒ 依「清标即落地」(维护者 2026-08-25),转 ready + 挂 auto-merge,与清标同一笔。

BASIS 4 的那条 scoped observation 不随本 PR 处置,但它不是没人管:publicSharing.enabled 关掉后 resolveToken 不再有任何策略门这件事,正是你自己在「Out of scope, filed」里立的 #13856(p1,security,domain:services 队列里)。复审席独立地从契约面又撞到同一处 ⇒ 两条独立证据指向同一张卡,该卡的定级站得住。


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 00:02
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit fc9ba76Sep 1, 2026
47 checks passed
@os-steve
os-steve deleted the claude/issue-13608-eligibility-at-redemption branch September 1, 2026 00:38
zhuangjianguo pushed a commit that referenced this pull request Sep 1, 2026
The merge of origin/main routed content/docs/permissions/system-context.mdx
through the os-regen driver, which exits 0 without text-merging and leaves
git's pre-filled OURS side in place. That silently dropped the 16 anchor
re-points main had landed (#13829, #13934, #13910, #13857) while keeping this
branch's single re-point.
This commit takes main's side of the page and re-derives every anchor from the
merged tree with `pnpm gen:system-context-census`, which re-pointed row 21's
metadata-protocol/src/protocol.ts anchor to 1736. Prose is byte-identical on
both sides once line numbers are normalised, so nothing but line numbers moved.
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 1, 2026
…, so `rollbackToPackageCommit` stops planning off the weekday name (objectstack-ai#14036)
* fix(metadata-protocol): order the ADR-0067 commit timeline by instant, not by the weekday name
`created_at` is an engine-injected audit column: not in `datetimeFields`, and
`SqlDriver#formatOutput` repairs it only inside `if (this.isSqlite)`. The live
SQL dialects therefore hand it out of the record read door as a JS `Date` while
the SQLite family hands out canonical ISO-Z text.
Both ADR-0067 commit-timeline consumers compared `String(created_at)`, and
`String(aDate)` is `"Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)"` —
the LEADING token is the weekday NAME, so lexicographic order over those strings
is `Fri < Mon < Sat < Sun < Thu < Tue < Wed`. Unrelated to chronology, and
stable across the whole set, so it is wrong on every run and wrong the same way.
- `listCommits` returned the timeline in weekday-name order while claiming
newest-first; its own comment stated the assumption ("sort by the ISO
timestamp") and it was false on the production default driver.
- `rollbackToPackageCommit` both consumed that ordering and re-derived the same
comparison itself, so neither site could correct the other: it reverted
`apply` commits OLDER than the target and skipped the newer ones it exists to
undo.
Both sites now compare canonical absolute instants through `compareAuditInstants`,
a sibling of the `canonicalVersionInstant` helper objectstack-ai#13382 landed one seam over in
this same file. The canonicalisation is reused; the ordering is new, because
`versionTokensAgree` answers equality between client-supplied version tokens and
an ordering question needs `<`/`>`. When either side does not denote an instant
the two are compared verbatim exactly as before, so only instant-bearing pairs
change verdict.
The pin drives a hand-made `Date` — `@objectstack/metadata-protocol` has no
driver dependency and must not grow one — over four consecutive days, the
smallest fixture for which no timezone alignment can make the old weekday
comparison agree with chronology.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
* chore(gates): re-point the isSystem census anchor and register the new engine double
Both are the gates' own sanctioned repairs for the line/ledger movement the fix
caused, applied with their own tooling and inspected:
- `check-system-context-census --fix` RE-POINTED row 21's anchor
`metadata-protocol/src/protocol.ts:1664` -> `:1736`, the 72-line shift the new
`compareAuditInstants` helper block introduced above it. No row was deleted and
no needle changed; the gate then reports 109 elevation read sites, 145 anchors
resolving.
- `check-engine-double-contract --write` ADDED one row recording that the new pin
file pins 1 `findOne` double ("1 added or grown, 0 lost"). The shrink-only
baseline is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
* chore(docs): re-derive the isSystem census after merging origin/main
The merge of origin/main routed content/docs/permissions/system-context.mdx
through the os-regen driver, which exits 0 without text-merging and leaves
git's pre-filled OURS side in place. That silently dropped the 16 anchor
re-points main had landed (objectstack-ai#13829, objectstack-ai#13934, objectstack-ai#13910, objectstack-ai#13857) while keeping this
branch's single re-point.
This commit takes main's side of the page and re-derives every anchor from the
merged tree with `pnpm gen:system-context-census`, which re-pointed row 21's
metadata-protocol/src/protocol.ts anchor to 1736. Prose is byte-identical on
both sides once line numbers are normalised, so nothing but line numbers moved.
---------
Co-authored-by: Claude <noreply@anthropic.com>
os-sales pushed a commit that referenced this pull request Sep 2, 2026
…ly at mint
`ShareLinkService.resolveToken()` now reads the object's CURRENT
`publicSharing` block on every redemption and refuses when `enabled` is
not `true` — before the record probe, before the usage stamp, before any
sibling key inside the block is evaluated. Re-enabling the block restores
the same tokens (a standing policy, not a revocation). How a link was
minted buys it nothing at redemption: rows minted under a system context
or the `permissive` bypass on a switched-off object refuse the same way,
and an object with no block at all is the same switch at its default.
The refusal reuses the undifferentiated `null` a revoked / expired /
unknown / ineligible token already gets — no new error code, no new
response branch, no usage stamp (over HTTP the generic 404). The readable
reason goes to the server-side log at `warn`, where the eligibility
refusal already writes its own.
Tests: the standing-policy pins (fake engine and real driver), the
HTTP-seam shape, the log line, the bypass-minted path, and the reversal
register for the #13856 pins that asserted serving on a disabled block.
Changeset marked as a breaking runtime change in PR #13857's form.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sharing: publicSharing.eligibility is evaluated only at mint — a link keeps serving a record after it stops being eligible

3 participants

@os-warren@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

Hold publicSharing.eligibility at redemption, not only at mint - #13857

Merged
os-steve merged 4 commits into
mainfrom
claude/issue-13608-eligibility-at-redemption
Sep 1, 2026
Merged

Hold publicSharing.eligibility at redemption, not only at mint#13857
os-steve merged 4 commits into
mainfrom
claude/issue-13608-eligibility-at-redemption

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes#13608

publicSharing.eligibility was enforced at mint (createLinkassertEligible) and never again. resolveToken() checked revoked_at, expires_at, the audience gates, the password and record EXISTENCE — then served whatever survived, under SYSTEM_CTX, to a caller with no principal. Publish an article published + public, mint a link, flip audience to internal or status back to draft, and the old token kept resolving and kept serving the record in full. Declared ≠ enforced.

Triage's strongest argument is carried into the code comment: in that same door, recordStillExists is deliberately fail-CLOSED, so a deleted record stopped being served immediately while a reclassified one did not — two failure directions in one door, and the governed side wins.

Premise re-verified against packages/plugins/plugin-sharing/src/ on the base (the card measured the installed dist/index.js): located by symbol — createLink and assertEligible at the mint seam, resolveToken / getPolicy / recordStillExists at the serving seam. The card's reading holds on source, verbatim.

The four ruled items (maintainer ruling 2026-08-31, comment 5478574829)

1. Re-evaluate in resolveToken() before serving, fail-closed. The gate calls assertEligible itself — the same function the mint path calls — so mint and redemption cannot drift on strictness (true, never merely truthy), on the declared-field binding, or on which faults refuse. It is ONE read either way: when a predicate is declared, the existence probe's projection widens from ['id'] to the whole row instead of a second query being issued (loadRecordForServing(object, recordId, withRecord)), so an object with no eligibility key keeps the exact probe it always had — pinned in both directions. A policy tightening cutting off already-minted links is recorded as a behaviour change in the changeset and in migration-facing wording (item 3).

2. The refusal reuses the existing branch — measured at both seams.stillEligible catches assertEligible's throw and returns false; resolveToken answers null, the same value revoked, expired and record-gone already return. No new error code (the ADR-0112 ledger is untouched), no new response branch, no usage stamp, and nothing thrown that a route could turn into a 422. See the measurement section below for what an anonymous caller actually observes.

3. The documentation half ships here. Stated beside the declaration key (packages/spec/src/data/object.zod.ts — the eligibility TSDoc and its .describe(), which projects into content/docs/references/data/object.mdx), on the contract (IShareLinkService.resolveToken plus design note 6), and in the hand-written sharing security page with an upgrade callout (content/docs/protocol/objectql/security.mdx).

4. Option B is not here, and neither is the scope ban's subject. No write-time revocation, no audit trail, no hook mechanism; the diff adds no hook surface and touches nothing about hooks on platform objects an app declares a dependency on. The whole diff is 7 files: the service, its test, two spec docs sites, two docs pages, one changeset.

The refusal-shape measurement — and one honest reading

At the service seam, five arms were driven in one live backend and compared by IDENTITY, not by shape: reclassified · revoked · expired · record-gone · unknown token. All five return the same value, null. Nothing on the wire distinguishes them.

At the HTTP seam the real route was mounted on the real service and driven anonymously (the route's secure default context):

casestatuscode
reclassified after mint404INVALID_OR_EXPIRED
token that never existed404INVALID_OR_EXPIRED
revoked link410EXPIRED_OR_REVOKED

The reclassified answer is asserted deep-equal to the unknown-token answer, and asserted not to contain the words eligib or audience.

⚠️ Read this precisely, because it is not word-for-word "the same response as a revoked link": the reclassified link lands in the route's generic invalid / expired / revoked bucket, byte-identical to a token that never existed, and NOT in the 410 branch. That is forced by ruling item 2 itself — the route picks 410 off revoked_at / expires_at on the row, so reaching it would require the service to hand the route a distinguishable "ineligible" answer, which is exactly what item 2 forbids. And it is the strictly LESS informative of the two buckets: 410 would confirm to a holder that the token was real. The 410-vs-404 split between revoked and unknown is pre-existing, untouched here, and recorded in the test as measured rather than assumed.

Verification

Everything below was run on 926c1af6d (branch head), in a dedicated worktree, heavy runs serialized through scripts/pm/os-verify-lock.sh.

  • pnpm --filter @objectstack/plugin-sharing test30 files, 699 tests passed.
  • pnpm --filter @objectstack/plugin-sharing typecheck and pnpm --filter @objectstack/spec exec tsc --noEmit — clean.
    ⚠️ The package's tsconfig.jsonexcludes **/*.test.ts, so that typecheck says nothing about the new test code. Measured separately by reproducing the TEST_DEBT program (package tsconfig minus the test exclusion, dependency closure built): exactly 3 errors, the same TS6133 ×2 + TS18048 ×1 the ledger freezes, zero of them in the edited file — the ratchet does not move.
  • pnpm lint (eslint . --no-inline-config, whole repo) — clean.
  • Gate family re-derived from the ACTUAL diff (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack), both sections read. All ran green, including: check:authorable-surface, check:generated (14/14 artifacts current), check:docs, check:doc-authoring, check:yaml-examples, check:llms-txt, check:skill-refs, check:skill-examples, check:variant-docs, check:liveness, check:empty-state, check:strictness-ledger, check:i18n (9 packages in sync), check:i18n-stale-fill, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:cross-package-test-inputs, check:test-source-alias, check:type-check-coverage, check:system-context-census, check:nul-bytes, check:empty-changeset, check-adr-0087-registration, check-changeset-no-major, the doc gate family and the repo-root node gates.
  • Two gates needed their build prerequisite cleared first, and were then measured green rather than left as NOT MEASURED: check:i18n (the CLI + extract closure) and check:skill-examples (@objectstack/client-react declarations).
  • node scripts/check-test-completeness.mjs exits 3 = PREREQUISITE NOT MET locally (it parses a saved turbo run test log, which a local family run has none of) — recorded as NOT MEASURED, not as a pass.
  • Two generated artifacts moved with the source and are committed: content/docs/references/data/object.mdx (gen:schema then gen:docs) and the five re-anchored citations in content/docs/permissions/system-context.mdx (check-system-context-census --fix, pure line rot from this diff's line shifts).

Ablation

Predicted before the run: the eleven redemption pins redden; every mint-side pin and every "nothing else moved" control stays green.

The repair was committed first; the mutation was made in the working tree only, with a trap … EXIT INT TERM restoring by absolute path, and it was proved on disk before measuring — not by an editor's exit code:

[pre] HEAD blob = 3909d07fea4fd41585bf158319eaa97d76415b1a (working tree matches)
[mutate] two anchored replacements applied (0 hits would have aborted)
[post] mutated blob = 2e223b13668ed8aa6391f046ba1fada6580ccf1f
[markers] removed-call-sites=0 (want 0) · injected-markers=2 (want 2) · widening-sites=0 (want 0)
[vitest] exit=1
Test Files 1 failed (1)
Tests 11 failed | 22 passed (33)

The eleven that reddened:

× `audience` flipped to internal: the same token resolves, then does not
× `status` reverted to draft: the same token resolves, then does not
× at the service seam it is `null` — the identical value, not a lookalike
× at the HTTP seam an anonymous caller cannot tell it from an unknown token
× the ineligible arm returns — it never throws a code an anonymous caller could read
× a refused redemption stamps no usage — exactly like a revoked one
× a predicate that no longer compiles refuses a record that still qualifies
× a predicate naming an UNDECLARED key refuses as a fault, not as a verdict
× a predicate resolving to a non-boolean has not consented
× the refusal reason names the two things an operator needs, and stays server-side
× the eligibility read WIDENS the same probe rather than adding a second query

Restore proved by state, not by the trap firing: git status clean, git diff HEAD empty, git hash-object back to the HEAD blob 3909d07f…, zero ablation markers left; the file re-ran 33/33 green afterwards.

⚠️ The first ablation run reddened only TEN, and that is the run worth reporting: "at the service seam it is null" passed with the gate fully removed, because it deleted the reclassified record to exercise the #5190 arm — so the existence probe answered null for the reclassified token too, and the assertion held for a reason that has nothing to do with eligibility. A green-in-both-directions case is a declared control, never ablation evidence. It was repaired (the record-gone arm now has its own record; the reclassified one stays in the table) and the pin then reddened. The gate's own arms are still measured as controls: the two #5190 deleted-record cases, the still-eligible resolve with its redactFields set unchanged, the no-predicate object, and the id-only projection all stay green under ablation.

⛔ Clause ② — declared, not self-cleared

This diff hits the clause-② path limb: packages/spec/src/contracts/share-link-service.ts and packages/spec/src/data/object.zod.ts (both under packages/spec/src/**, named by the re-derivation as the suspect surface). No accept-set moves and no key is added, renamed or removed — the spec change is documentation of semantics the runtime now holds — but that judgement is not mine to make. Handed back to the PM: this may not enqueue until the contract review clears it.

Out of scope, filed

Generated by Claude Code


Generated by Claude Code

…demption
`ShareLinkService.resolveToken()` now re-evaluates the object's declared
`publicSharing.eligibility` predicate against the record it is about to
serve, through the same `assertEligible` the mint path calls. A record
reclassified out of the policy after a link was minted stops being served
through that link; an unevaluable predicate refuses (fail-closed), matching
mint.
The refusal reuses the undifferentiated `null` a revoked, expired or unknown
token already gets — no new error code, no new response branch, no usage
stamp — because distinguishing those cases for a caller with no principal is
an existence oracle. The readable reason goes to the server-side log.
Docs: the semantics are now stated beside the `publicSharing.eligibility`
declaration key, on `IShareLinkService.resolveToken`, and in the hand-written
sharing security page with an upgrade note.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…string
`check:doc-authoring` refuses issue-id citations in customer-facing strings
(maintainer ruling 2026-08-12) — `.describe()` prose projects into
content/docs/references/** and the generated skill artifacts, where `#NNNN`
resolves to nothing. The sentence is rephrased rather than truncated; the
reference stays in the adjacent TSDoc, which only an internal reader sees.
Also re-anchors the five system-context census citations this branch's line
shifts moved (`check:system-context-census --fix`).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
The first ablation run measured this case GREEN with the redemption gate
removed: it deleted the reclassified record to exercise the #5190 arm, so the
record-existence probe answered `null` for the reclassified token too and the
assertion held for a reason that has nothing to do with eligibility. The
record-gone arm now gets its own record, and the reclassified one stays in the
table — ineligible, not gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-sharing, @objectstack/spec, touching 7 documentable anchor(s).

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

  • content/docs/protocol/objectql/security.mdx(via publicSharing (symbol, a field of const object ObjectSchemaBase))
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 — 127 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 f532630d0246e93b36f869579eb5fa4184d51141packageMentionDocs.

Which tree this was computed on

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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f532630d0246e93b36f869579eb5fa4184d51141 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-warrenClaude

Copy link
Copy Markdown
Collaborator

Contract review (Clause ②) — PASS

Reviewed at head 926c1af6d7bd2bb19195aeffc8354e4860ef8c77, still the head. Rendered by a CONTRACT_REVIEW_TIER reviewer in an isolated context; transcript tier-verified before adoption (36 harness-stamped assistant turns, 100% at tier, first and last included, service_tier: standard throughout, zero fallback evidence). The triage seat runs below tier and therefore adopts verbatim or voids whole — no rewriting, trimming or softening. Adopted verbatim, unedited:

VERDICT: PASS
CLAUSE-2-PATH: yes
CLAUSE-2-CONTENT: no
DECLARATION-HONEST: yes
ONE-LINE: PASS — the spec half of the diff moves no accept-set and widens no surface (its one normative addition transcribes maintainer ruling item 2 onto an already-published, unchanged SPI shape), so only the path limb fires and every factual clause in the PR's declaration checked out true against the actual diff; the needs:contract-review label may be cleared.
FINDINGS:
- Path limb fires on exactly two files: "packages/spec/src/contracts/share-link-service.ts" and "packages/spec/src/data/object.zod.ts", both under packages/spec/src/**, out of 8 changed files enumerated from the API.
- Zod accept-set is unmoved: the changed line still reads "eligibility: z.string().optional().describe(" on both sides; the diff around it is TSDoc and describe-string only, "redactFields: z.array(z.string()).optional()" and the closing "}).optional().describe('Public share-link policy" are untouched, so the strictObject accepted key set is byte-identical.
- Content limb argued NO, not asserted: IShareLinkService IS published — package.json exports has a "./contracts" subpath and the barrel carries "export * from './share-link-service.js';" — so the new "Implementations MUST NOT return a distinguishable answer per reason, and MUST NOT throw one either" is published normative text; but it adds no method, key, or accepted input (it NARROWS conforming implementations), and it transcribes ruling item 2 verbatim ("⛔ 不新设「不再合格」的可区分应答", comment 5478574829) — the behaviour it makes normative is exactly the runtime security change the 2026-08-28 negative boundary assigns to the maintainer floor, whose control (the ruling) has been exercised; ruling item 3 ordered this documentation to ship with the card ("C 的文档半边必做,随本卡交付"). Same reasoning covers the .describe() rewrite and design note 6: they document WHEN an unchanged-shape key is enforced; the projected "content/docs/references/data/object.mdx" row changes text, not shape.
- Declaration honest, with one nuance: "This diff hits the clause-② path limb" — true; "No accept-set moves and no key is added, renamed or removed" — true against the patch; "the spec change is documentation of semantics the runtime now holds" — true but understated, since the resolveToken TSDoc is normative MUST-NOT language binding all implementers, not mere description; the body's own "that judgement is not mine to make" hands exactly that call here, so it is not a false claim.
- Machine spelling ABSENT from the claim comment (systemic gap, recorded not fixed): comment 5479312665 says "档位:**条款② 按实际 diff 在入队闸门判**(裁决原文如此)" — neither "Clause-②: yes" nor "Clause-②: no" appears verbatim, though SKILL.md requires "恰这两种拼写:`Clause-②: yes` / `Clause-②: no`" in the claim comment and ensure-pm-labels.sh confirms the gate reads "the card's claim comment". Control: "条款②" does hit in that comment. Enqueue remains blocked by the path limb regardless, so this does not change this card's gating outcome.
- Body's file count is off by one: "The whole diff is 7 files" but the API enumerates 8 — the uncounted file is "content/docs/permissions/system-context.mdx", which the body itself discloses elsewhere as "the five re-anchored citations in content/docs/permissions/system-context.mdx" (pure line-anchor rot, ":413" → ":423" etc.). Inaccurate arithmetic, not scope smuggling.
- Changeset honest: the file itself grades "@objectstack/plugin-sharing": minor and "@objectstack/spec": minor with a "**BREAKING** runtime behaviour change" banner — file matches body (unlike the sibling PR's patch-vs-minor mismatch), and "minor" is the repo's ceiling: scripts/check-changeset-no-major.mjs states "During the launch window we ship breaking changes as `minor`" and fails any major.
- ADR-0087 claim holds: "<!-- adr-0087: not-required (no-migration-prescription)" is present and its factual basis — "publicSharing.eligibility keeps its name, its type and its accept-set" — is confirmed by the zod patch; nothing authorable is removed, renamed, or re-shaped.
- Scope verified: no write-time revocation, no audit-trail rows, no hook mechanism anywhere in the 8-file diff (the only new observability is the server-side "this.logger?.warn?.('[share-link] redemption refused" line, which ruling item 2 itself directs to the log); nothing under content/docs/releases/ is touched (control: the 8-file enumeration is non-empty and fully listed).
- Head fresh and green: the branch tip is still 926c1af6d7bd2bb19195aeffc8354e4860ef8c77 (the brief's SHA), and all 36 check runs at that head are completed — 33 success, 3 skipped, none failed, none in_progress.

Carrier action

needs:contract-review is being cleared from both carriers in this same stroke — this PR and card #13608. Provenance: maintainer ruling 2026-08-25 ("审核通过你应该直接负责合并吧,还要等原始的项目经理吗"), under which a PASS clears the gate label rather than parking it for a second opinion.

⚠️The landing action is NOT mine and I am not taking it. Contract review belongs to the card's dispatching seat (domain:services here); the triage seat's standing round is explicitly "非放行必要条件,在线时作事后审计/抽查" and "⛔ 不与席内复核抢同一载体的放行动作". So this is an audit PASS: the gate is cleared, and the pre-landing checks → flip ready → enqueue sequence in landing-operations.md is the dispatching seat's to run. ⛔ Not flipping ready, not arming auto-merge, not approving — 席位永不代批 is unchanged.

Two record defects worth fixing while you are here — neither blocks

  1. The body says "The whole diff is 7 files"; it is 8. The uncounted one is content/docs/permissions/system-context.mdx, which the body itself discloses two sections later. Arithmetic, not smuggling — but the sentence is used as a scope guarantee, so it should be right.
  2. Card sharing: publicSharing.eligibility is evaluated only at mint — a link keeps serving a record after it stops being eligible #13608's claim comment carries no machine Clause-②: line. It reads "档位:条款② 按实际 diff 在入队闸门判(裁决原文如此)". The gate's declaration limb reads that comment and fixes exactly two spellings. Here the path limb fired and held the PR regardless, so nothing was lost — but on a PR touching no contract path, that comment is the only limb that can fire. Systemic; filed as The Clause-②: yes | no machine spelling is missing from the claim comment on 2 of 3 measured cards — the enqueue gate's predicate reads it there, and it is not there #13914.

Generated by Claude Code

@os-steveClaude

Copy link
Copy Markdown
Collaborator

契约复审:PASS —— 逐字采纳,标签已清,同笔落地

你在 PR 正文里把条款②路径肢声明而不自清(packages/spec/src/contracts/share-link-service.ts + packages/spec/src/data/object.zod.ts),交回 PM —— 这是对的做法。复审已执行完毕。

复审席资格,机读在案(⛔ 不是自述):

  • 降档保险丝:本席自会话 get_session 读回 last_served_modelCONTRACT_REVIEW_TIER自会话不得复审,改派达档子代理。
  • 转录核验(采信前必做):子代理 transcript 中 harness 逐消息盖章的 model 字段分布 ——
assistant 轮次: 89
盖章 "claude-fable-5": 89
非 fable 盖章(回退证据): 0

⇒ 产出裁决的每一轮都在档,零回退 ⇒ 裁决合法,按「逐字采纳或整体作废」二选一中的逐字采纳。下面是复审席的原文,⛔ 未改写、未删节、未润色:

CONTRACT REVIEW — PR #13857 @ 926c1af6d
VERDICT: PASS
INCREMENT REVIEWED: packages/spec/src/contracts/share-link-service.ts, packages/spec/src/data/object.zod.ts (diffed 16c3601d2 [merge-base with origin/main] → 926c1af6d)
FINDINGS:
none (one scoped observation, not a defect, recorded in BASIS item 4)
BASIS:
1. Schema shape — UNCHANGED. Comment-stripped code diff of both files between merge-base and head:
contracts/share-link-service.ts is byte-identical (JSDoc-only change); object.zod.ts differs by
exactly one string literal — the `.describe()` on `publicSharing.eligibility`. The zod chain is
`z.string().optional()` on both sides; no key added/removed/renamed, no optionality flipped, no
accept-set moved. Machine-confirmed: `check:spec-changes` green at head with `spec-changes.json`
untouched by the PR (0 baseline files in the diff).
2. Published surface — NOT WIDENED. Built `packages/spec` dist at head and ran `check:api-surface`:
"public API surface + factory signatures unchanged ✓". No new exports; `IShareLinkService`,
`resolveToken` signature and `ResolveShareLinkResult` are byte-identical in code.
3. Prose vs runtime — TRUE, verified against packages/plugins/plugin-sharing/src/share-link-service.ts
at head and executable-confirmed by its test suite (share-link-eligibility.test.ts: 33/33 pass):
- mint: `assertEligible` called in `createLink` before the insert → 422, no link row (pinned:
"a DRAFT record is refused with 422 and mints nothing");
- redemption: every `resolveToken` re-evaluates via `stillEligible`, which calls the SAME
`assertEligible` — one implementation at both seams, as the header note claims (pinned:
reclassified record "resolves, then does not");
- fail-closed at both points: compile failure, eval fault, and non-strict-`true` verdict all
refuse (three ELIGIBILITY_UNEVALUABLE / RECORD_NOT_ELIGIBLE arms in `assertEligible`; pinned
at both seams);
- undifferentiated `null`: identical value to revoked/expired/unknown, never a thrown readable
code, HTTP-seam indistinguishable, reason goes to `logger.warn` only (all four pinned);
- "use_count/last_used_at on SUCCESSFUL resolution only": stamp sits after every gate,
immediately before the successful return (pinned: "a refused redemption stamps no usage");
- null-on: missing/short token, revoked, expired, audience, password, record-gone (#5190),
ineligible (#13608) — every listed arm present in `resolveToken`, each `return null`;
- "Evaluator is the same one used by sharing rules": both consume the canonical CEL front end
in @objectstack/formula (`parseCelToAstWithReason`, convergence #6132; sharing rules lower
the same parsed AST via `compileCelToFilter`);
- prose narrowing "CEL/JSONLogic" → "CEL" matches the runtime (dialect hardcoded `'cel'`).
4. Commitments — nothing promised beyond what ships; the fail-closed and both-points claims are
implemented and pinned. Scoped observation (not a defect): enforcement is governed by the parent
`publicSharing.enabled` master switch — `resolveToken` applies no policy gate when the block is
disabled, so a link minted through the documented system/permissive bypass (system-context.mdx
ledger row 37, refreshed in this PR), or minted while enabled and later orphaned by the author
disabling the whole block, redeems without the eligibility re-check. This is pre-existing
resolveToken structure, uniform across all sibling policy keys (`redactFields` likewise), not
introduced or altered by this increment, and the key's describe — like its siblings' — speaks
within an in-force policy.
5. Generated artifacts — PROPAGATED AND CONSISTENT. Full `check:generated` aggregate run at head in
a scratch worktree (after `gen:schema` + package build, both environment prerequisites, not
staleness): all 14 artifacts up to date, including `check:docs` ("230 generated files in sync",
exit 0). Repo-wide grep at head finds the eligibility describe in exactly two committed files:
the zod source and the regenerated `content/docs/references/data/object.mdx`, whose table row
matches the new `.describe()` verbatim. The build-time json-schema output (gitignored) emits the
new description verbatim at `properties.publicSharing.properties.eligibility.description`;
`json-schema.manifest/` is a name-set ratchet carrying no description text, correctly unmoved.
No generated page consumes the contracts JSDoc (it ships via built .d.ts hover text and the npm
tarball source only).

PM 侧收口

  • needs:contract-review 已清(PASS 才清标;FAIL 是 label-flip 交接,标不清)。
  • 落地前检,按最终文件清单当场重跑、⛔ 非回忆:node scripts/pm/check-governed-merges.mjs --test × 8 条路径 ⇒ 0 of 8 path(s) hit the register非受管面,走普通队列⚠️ 这条必须当场跑:packages/spec/src/** 在面上时最容易凭印象判成受管。
  • 全 check 绿(42 个 check run,无 failure),mergeable_state: clean,head 926c1af6d 与复审席核的是同一个 sha
  • ⇒ 依「清标即落地」(维护者 2026-08-25),转 ready + 挂 auto-merge,与清标同一笔。

BASIS 4 的那条 scoped observation 不随本 PR 处置,但它不是没人管:publicSharing.enabled 关掉后 resolveToken 不再有任何策略门这件事,正是你自己在「Out of scope, filed」里立的 #13856(p1,security,domain:services 队列里)。复审席独立地从契约面又撞到同一处 ⇒ 两条独立证据指向同一张卡,该卡的定级站得住。


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 00:02
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit fc9ba76Sep 1, 2026
47 checks passed
@os-steve
os-steve deleted the claude/issue-13608-eligibility-at-redemption branch September 1, 2026 00:38
zhuangjianguo pushed a commit that referenced this pull request Sep 1, 2026
The merge of origin/main routed content/docs/permissions/system-context.mdx
through the os-regen driver, which exits 0 without text-merging and leaves
git's pre-filled OURS side in place. That silently dropped the 16 anchor
re-points main had landed (#13829, #13934, #13910, #13857) while keeping this
branch's single re-point.
This commit takes main's side of the page and re-derives every anchor from the
merged tree with `pnpm gen:system-context-census`, which re-pointed row 21's
metadata-protocol/src/protocol.ts anchor to 1736. Prose is byte-identical on
both sides once line numbers are normalised, so nothing but line numbers moved.
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 1, 2026
…, so `rollbackToPackageCommit` stops planning off the weekday name (objectstack-ai#14036)
* fix(metadata-protocol): order the ADR-0067 commit timeline by instant, not by the weekday name
`created_at` is an engine-injected audit column: not in `datetimeFields`, and
`SqlDriver#formatOutput` repairs it only inside `if (this.isSqlite)`. The live
SQL dialects therefore hand it out of the record read door as a JS `Date` while
the SQLite family hands out canonical ISO-Z text.
Both ADR-0067 commit-timeline consumers compared `String(created_at)`, and
`String(aDate)` is `"Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)"` —
the LEADING token is the weekday NAME, so lexicographic order over those strings
is `Fri < Mon < Sat < Sun < Thu < Tue < Wed`. Unrelated to chronology, and
stable across the whole set, so it is wrong on every run and wrong the same way.
- `listCommits` returned the timeline in weekday-name order while claiming
newest-first; its own comment stated the assumption ("sort by the ISO
timestamp") and it was false on the production default driver.
- `rollbackToPackageCommit` both consumed that ordering and re-derived the same
comparison itself, so neither site could correct the other: it reverted
`apply` commits OLDER than the target and skipped the newer ones it exists to
undo.
Both sites now compare canonical absolute instants through `compareAuditInstants`,
a sibling of the `canonicalVersionInstant` helper objectstack-ai#13382 landed one seam over in
this same file. The canonicalisation is reused; the ordering is new, because
`versionTokensAgree` answers equality between client-supplied version tokens and
an ordering question needs `<`/`>`. When either side does not denote an instant
the two are compared verbatim exactly as before, so only instant-bearing pairs
change verdict.
The pin drives a hand-made `Date` — `@objectstack/metadata-protocol` has no
driver dependency and must not grow one — over four consecutive days, the
smallest fixture for which no timezone alignment can make the old weekday
comparison agree with chronology.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
* chore(gates): re-point the isSystem census anchor and register the new engine double
Both are the gates' own sanctioned repairs for the line/ledger movement the fix
caused, applied with their own tooling and inspected:
- `check-system-context-census --fix` RE-POINTED row 21's anchor
`metadata-protocol/src/protocol.ts:1664` -> `:1736`, the 72-line shift the new
`compareAuditInstants` helper block introduced above it. No row was deleted and
no needle changed; the gate then reports 109 elevation read sites, 145 anchors
resolving.
- `check-engine-double-contract --write` ADDED one row recording that the new pin
file pins 1 `findOne` double ("1 added or grown, 0 lost"). The shrink-only
baseline is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
* chore(docs): re-derive the isSystem census after merging origin/main
The merge of origin/main routed content/docs/permissions/system-context.mdx
through the os-regen driver, which exits 0 without text-merging and leaves
git's pre-filled OURS side in place. That silently dropped the 16 anchor
re-points main had landed (objectstack-ai#13829, objectstack-ai#13934, objectstack-ai#13910, objectstack-ai#13857) while keeping this
branch's single re-point.
This commit takes main's side of the page and re-derives every anchor from the
merged tree with `pnpm gen:system-context-census`, which re-pointed row 21's
metadata-protocol/src/protocol.ts anchor to 1736. Prose is byte-identical on
both sides once line numbers are normalised, so nothing but line numbers moved.
---------
Co-authored-by: Claude <noreply@anthropic.com>
os-sales pushed a commit that referenced this pull request Sep 2, 2026
…ly at mint
`ShareLinkService.resolveToken()` now reads the object's CURRENT
`publicSharing` block on every redemption and refuses when `enabled` is
not `true` — before the record probe, before the usage stamp, before any
sibling key inside the block is evaluated. Re-enabling the block restores
the same tokens (a standing policy, not a revocation). How a link was
minted buys it nothing at redemption: rows minted under a system context
or the `permissive` bypass on a switched-off object refuse the same way,
and an object with no block at all is the same switch at its default.
The refusal reuses the undifferentiated `null` a revoked / expired /
unknown / ineligible token already gets — no new error code, no new
response branch, no usage stamp (over HTTP the generic 404). The readable
reason goes to the server-side log at `warn`, where the eligibility
refusal already writes its own.
Tests: the standing-policy pins (fake engine and real driver), the
HTTP-seam shape, the log line, the bypass-minted path, and the reversal
register for the #13856 pins that asserted serving on a disabled block.
Changeset marked as a breaking runtime change in PR #13857's form.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sharing: publicSharing.eligibility is evaluated only at mint — a link keeps serving a record after it stops being eligible

3 participants

@os-warren@os-steve@claude