Uh oh!
There was an error while loading. Please reload this page.
Hold publicSharing.eligibility at redemption, not only at mint - #13857
Conversation
…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
…igibility-at-redemption
…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
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 127 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
os-warren
commented
Aug 31, 2026
Contract review (Clause ②) — PASSReviewed at head Carrier action
Two record defects worth fixing while you are here — neither blocks
Generated by Claude Code |
os-steve
commented
Sep 1, 2026
契约复审:PASS —— 逐字采纳,标签已清,同笔落地你在 PR 正文里把条款②路径肢声明而不自清( 复审席资格,机读在案(⛔ 不是自述):
⇒ 产出裁决的每一轮都在档,零回退 ⇒ 裁决合法,按「逐字采纳或整体作废」二选一中的逐字采纳。下面是复审席的原文,⛔ 未改写、未删节、未润色: PM 侧收口
⭐ BASIS 4 的那条 scoped observation 不随本 PR 处置,但它不是没人管: Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
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.
…, 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>
…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
Closes#13608
publicSharing.eligibilitywas enforced at mint (createLink→assertEligible) and never again.resolveToken()checkedrevoked_at,expires_at, the audience gates, the password and record EXISTENCE — then served whatever survived, underSYSTEM_CTX, to a caller with no principal. Publish an articlepublished+public, mint a link, flipaudiencetointernalorstatusback todraft, 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,
recordStillExistsis 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 installeddist/index.js): located by symbol —createLinkandassertEligibleat the mint seam,resolveToken/getPolicy/recordStillExistsat 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 callsassertEligibleitself — 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 noeligibilitykey 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.
stillEligiblecatchesassertEligible's throw and returnsfalse;resolveTokenanswersnull, 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— theeligibilityTSDoc and its.describe(), which projects intocontent/docs/references/data/object.mdx), on the contract (IShareLinkService.resolveTokenplus 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):
INVALID_OR_EXPIREDINVALID_OR_EXPIREDEXPIRED_OR_REVOKEDThe reclassified answer is asserted deep-equal to the unknown-token answer, and asserted not to contain the words
eligiboraudience.revoked_at/expires_aton 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 throughscripts/pm/os-verify-lock.sh.pnpm --filter @objectstack/plugin-sharing test— 30 files, 699 tests passed.pnpm --filter @objectstack/plugin-sharing typecheckandpnpm --filter @objectstack/spec exec tsc --noEmit— clean.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.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.check:i18n(the CLI + extract closure) andcheck:skill-examples(@objectstack/client-reactdeclarations).node scripts/check-test-completeness.mjsexits 3 = PREREQUISITE NOT MET locally (it parses a savedturbo run testlog, which a local family run has none of) — recorded as NOT MEASURED, not as a pass.content/docs/references/data/object.mdx(gen:schemathengen:docs) and the five re-anchored citations incontent/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 TERMrestoring by absolute path, and it was proved on disk before measuring — not by an editor's exit code:The eleven that reddened:
Restore proved by state, not by the trap firing:
git statusclean,git diff HEADempty,git hash-objectback to the HEAD blob3909d07f…, zero ablation markers left; the file re-ran 33/33 green afterwards.null" passed with the gate fully removed, because it deleted the reclassified record to exercise the #5190 arm — so the existence probe answerednullfor 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#5190deleted-record cases, the still-eligible resolve with itsredactFieldsset unchanged, the no-predicate object, and theid-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.tsandpackages/spec/src/data/object.zod.ts(both underpackages/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
publicSharingoff leaves already-minted links serving — and silently drops the object'sredactFields#13856 — turningpublicSharingOFF leaves already-minted links serving, and (the fail-open half) silently drops the object's defaultredactFields, becausegetPolicyreturns an empty policy whenenabled !== true. Filed unassigned; deliberately not carried here, since what de-opt-in should mean for an existing token is a policy question this card's ruling did not answer.Generated by Claude Code
Generated by Claude Code