fix(sharing): gate the share-link route probe on publicSharing.enabled, at both probe sites - #14905
Conversation
…d at both sites The route-level probe above `resolveToken` answered from the token row with no knowledge of the object's standing `publicSharing.enabled` policy, so a real-but-switched-off link carrying a `password_hash` still drew `401 NEEDS_PASSWORD` / `WRONG_PASSWORD` and one with `audience: 'signed_in'` still drew `401 SIGN_IN_REQUIRED` — the existence oracle `share-link-service` states in prose that it closes, re-opened one layer up. Both probe sites read the policy before answering from the row, and every arm (the 410 included) falls through to the generic `404 INVALID_OR_EXPIRED` that unknown, revoked, expired and ineligible tokens already give. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…qual to unknown Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
The census page's line anchors into `share-link-service.ts` moved by exactly the 20 lines this branch inserted above `getPolicy`; repaired with the gate's own `--fix`, which rewrote 5 anchors and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…ope conformance double The envelope conformance harness stubs `SharingEngine` without `getSchema`, so under the gated probe every one of its four row arms (`NEEDS_PASSWORD`, `WRONG_PASSWORD`, `SIGN_IN_REQUIRED`, `EXPIRED_OR_REVOKED`) fell through to the generic 404 — correctly, and fail-closed, but the refusals whose ENVELOPE this module exists to pin were then unreachable. The double now declares the block for the object its probe rows name, and those rows name it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
📓 Docs Drift Check6 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 29 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 9701a0393659fdced10e148cc149164d12ba877c && git checkout 9701a0393659fdced10e148cc149164d12ba877c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f8e8f0350924457337200020845676e33ef3d6b8 43d3b2aad4ea7c303b1a65a5275d301399e045df && git checkout -B drift-repro f8e8f0350924457337200020845676e33ef3d6b8 && git merge --no-ff 43d3b2aad4ea7c303b1a65a5275d301399e045df
node scripts/docs-audit/affected-docs.mjs --json f8e8f0350924457337200020845676e33ef3d6b8 |
✅ PM ACCEPT — verified on the diff, with one asymmetry raised for the reviewer
The two re-judged fixtures — the part I checked hardestEditing an existing test to accommodate a behaviour change is where a real regression hides, so both were read line by line rather than taken from the report. 1. 2. ⛔ Nothing was skipped, disabled or quarantined. The gate, verified structurallyBoth sites write the generic refusal once as a local
|
| site | call |
|---|---|
share-link-routes.ts |
engine.getSchema?.(row.object_name) — optional call |
runtime/src/domains/share-links.ts |
engine?.getSchema(row.object_name) — optional chain on engine only |
At the plugin site an engine lacking getSchema yields undefined ⇒ false ⇒ refuse, fail-closed. At the runtime site the same engine would throw, and a 500 is distinguishable from the 404 an unknown token gets — which is the shape of the oracle this PR closes, arriving by a different door.
I have not measured whether that is reachable — the runtime's per-env ObjectQL does carry getSchema, so it may be unreachable in practice, and I am not claiming it is a defect. I am raising it because this PR's whole thesis is that the two surfaces must answer identically, and here they defend differently against the same missing method.
Everything else, checked
- Census page: one line, row 37's five anchors
:449 :503 :507 :580 :610→:469 :523 :527 :600 :630— exactly +20 each, matching the 20 lines added abovegetPolicy. Pure line rot, repaired with the gate's own--fix; no prose or count hand-edited. - Changeset:
minoron both packages with an explicit BREAKING banner and an ADR-0087not-required (no-migration-prescription)marker carrying its reasoning — the shape this repo requires for a breaking runtime change, not a bare patch. - Ablation: two legs, direction predicted first, each site's pins reddening only for that site (5/42 and 4/17), mutation proven on disk by literal-text counts and restore proven by blob-hash equality plus an empty
git diff HEAD. The report also states why no rebuild is owed (relative imports) instead of skipping the question. - Typecheck NOT-MEASURED guard honoured: plain
tsc --listFileshas 0 hits for the edited test files, so the package typecheck says nothing about them; they are intsconfig.test.json, which is where the green comes from. That is the phantom-green trap being avoided rather than walked into. - Suites:
plugin-sharing730 passed,runtime3126 passed. Full-repopnpm lintexit 0, no narrowing. - Card citations re-derived and all held — including
share-link-eligibility.test.ts:992-1022. Nothing in the dispatch order was stale this time, and the report says so explicitly rather than staying silent.
⛔ Not undrafted and not armed. Clause-② yes per the ruling; needs:contract-review is on the card and on this PR. The tier route is currently via the director seat (see #14866's precedent) — this PR waits for a verdict, not for CI colour.
Generated by Claude Code
Contract review (Clause-②
|
| # | Patch | Owner |
|---|---|---|
| 1 | Rewrite the objectui section with the measured facts: for a link on a switched-off object the console renders the 404 copy instead of the password prompt (all three 401 arms) and instead of "expired or was revoked" (the 410 arm); both are the accepted consequence of option A; no objectui code change is owed | dev, patch round |
| 2 | Fix the mirror's citation at share-links.ts:52 → share-links-enforcement-context.test.ts |
dev, patch round |
| 3 | Re-post the card's claim in the machine-legible spelling | this seat — done, see below |
| 4 | File the follow-up card for the duplicated predicate | this seat — filing now |
On patch 3: the script says ⛔ do not fill the line in on the claiming seat's behalf. This seat is the claiming seat for #14637, so posting it is not acting on anyone's behalf — it is this seat making its own declaration in the spelling the gate can read. The judgement itself is unchanged: Clause-②: yes, exactly as originally declared in prose.
On patch 4, the review overturned the dev's reason for not filing, and correctly: the card's "duplicated twin" is the pre-existing two-surface probe, not the predicate copy this PR introduces, and #14637 closes with this PR, so the copy would be untracked. It also refuted the PR's own justification for the mirror — "importing it would invert the dependency direction" is true only of the specific home proposed; five packages are already in the dependencies of both plugin-sharing and runtime.
Non-blocking notes carried forward, not lost
§5 notes 2, 3 and 4 are worth folding into the patch round even though they do not gate: the changeset's "Consumer impact" paragraph names only the password consequence while the 410→404 shift is equally consumer-visible (note 2 — and patch 1's measurement is exactly what makes that concrete); only the revoked half of the 410 arm is pinned, an expired case is cheap (note 3); and runtime's getEngine() host/scoped divergence is inherited, not widened (note 4 — record, do not fix here).
⛔ Stays draft and unarmed. needs:contract-review remains hung on this PR and on #14637 until the required patches land and this seat re-reads the delta.
Generated by Claude Code
The mirror's docblock told the next reader that the two spellings of `isPublicSharingEnabled` are held equal by pins in `share-links-probe-policy-gate.test.ts`. `git ls-tree -r` has zero entries for that name anywhere in the repo — the pins are in `share-links-enforcement-context.test.ts`, in this same directory. This lands on the change's own thesis: a security property stated in a comment is worth having only if the next reader can follow the comment to the thing that holds it. A citation to a file that does not exist is the same defect class the gate itself closes, one layer up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
The gated 410 arm is reached by two predicates, not one:
row.revoked_at || (row.expires_at && Date.parse(row.expires_at) <= Date.now())
Both sites pinned only the revoked half. A pin on that half alone leaves
the expired half free to keep answering `410 EXPIRED_OR_REVOKED` on a
switched-off object — the same existence oracle, reached by the other
predicate, and invisible to every assertion in the file.
Both new cases carry the same reverse check the siblings do (with the
block ON an expired link is still 410) and the same
`expectIndistinguishable` byte-equality assertion against the
unknown-token answer. Expiry is stamped on the stored row rather than
minted: `createLink` refuses a past `expiresAt` outright with
`422 EXPIRY_IN_PAST`, so back-dating the row is the only way to reach an
already-expired link — which is what the passage of time does to a live
one, and the stamp the file's existing #13608 pins already use.
The changeset's "Consumer impact" paragraph named only the password-prompt
consequence. The 410 shift is equally consumer-visible — a different
sentence in the objectui console, which branches on the refusal STATUS and
never on the body's error code — so it is now named too, with the measured
consumer and its line range.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Patch round verified against the artifacts — the contract review's
|
| # | Required | Verified how | Result |
|---|---|---|---|
| 1 | Rewrite the false objectui conclusion | read the live PR body over REST | ✅ |
| 2 | Fix the docblock's citation of a non-existent test file | git diff on share-links.ts |
✅ 1 insertion / 1 deletion |
| 3 | Legible clause-② declaration | check-clause2-carriers --pair 14905 |
✅ exit 0 (seat, earlier) |
| 4 | File the predicate-duplication follow-up | #14935 | ✅ (seat, earlier) |
Patch 1 is the one that mattered and it is done properly. The section no longer merely states the right thing — it names its own earlier claim as wrong, reproduces the honest-but-misdirected zero (re-run at objectui 67dadd6 across both apps/ and packages/), gives the status→copy table, and states the two consequences as accepted under option A rather than as regressions. It also found corroboration I had not: this repo's own checklist already records the console's 401-folding as a knownGap at docs/qa/platform-checklist/areas/access-security.json:2737, written before this card existed. That is independent of both the dev's grep and my read.
No code path changed, verified rather than asserted — the whole delta is 4 files:
.changeset/share-link-probe-policy-gate.md | 22 +++--
packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts | 29 ++++
packages/runtime/src/domains/share-links-enforcement-context.test.ts | 31 ++++
packages/runtime/src/domains/share-links.ts | 2 +-
The only non-test .ts change is the one-line docblock fix. So the two ablation legs recorded at 261cfb8d8 still describe the shipped gate, and ⛔ no new ablation was owed — the dev said so explicitly rather than silently omitting it, which is the right way to decline one.
The dev exceeded the note it was given, and correctly
Non-blocking note 3 asked for the 410 arm's expired half to be pinned. It pinned it at both sites, not just runtime, with the reason: the arm is reached by two predicates, so pinning one half at one site would have left the same oracle reachable by the other. That is the card's own thesis applied to its own test coverage. Suite counts confirm both pins actually ran: plugin-sharing 730 → 731, runtime 3126 → 3127.
Merge state — the conflict the dev reported has since dissolved
The report flagged mergeable_state: dirty with one conflicting file, content/docs/permissions/system-context.mdx. Re-measured just now against current origin/main (3210b3d5d): git merge-tree --write-tree exits 0 with zero CONFLICT lines. Main moved past it. The dev was right not to merge, and there is now nothing to resolve.
⭐ Its warning stands independently of that, and I am recording it because my own dispatch orders had this wrong: on that page, check:system-context-census --fix is the line-rot instrument and is the wrong tool for a conflict. The os-regen driver's recipe for a conflict is: take either side's anchors, run pnpm gen:system-context-census, and resolve the hand-written prose by hand — --fix cannot restore prose it never generated. Corrected with the still-running sibling dev; recorded here so the next reader of this PR does not inherit my error.
Status
needs:contract-review is now cleared from this PR and from #14637, both with a comparative read-back. Remaining before landing: CI green on 889e30d31, then undraft and arm. ⛔ Not armed yet.
Recorded-not-fixed, per the review and unchanged: the contract-violating engine occupant that throws from getSchema (escapes to errorFromThrown(err, 500) rather than the fail-closed 404 — guarding it would mean tolerating a contract violation, which contract-first sends back to the producer), and getEngine()'s host-vs-scoped divergence, which this gate inherits and does not widen.
Generated by Claude Code
…are-link-probe-policy-gate # Conflicts: # content/docs/permissions/system-context.mdx
|
Interim update (merge round, part 1 of 2) — conflict resolved and pushed, CI confirmed started. Conflict: exactly the one file the brief named,
Resolution: Proof no prose was dropped: normalized both the merged copy and main's copy through the driver's own Regeneration: ran Gate: Pushed: CI: confirmed started on the new head — 16 check runs already Continuing with the package-suite re-verification and gate re-derivation now; final report to follow. Generated by Claude Code Generated by Claude Code |
|
os-dev-report {
"issue": 14637,
"status": "done",
"branch": "claude/issue-14637-share-link-probe-policy-gate",
"pr": "https://github.com/objectstack-ai/objectstack/pull/14905",
"premise_still_valid": true,
"summary": "Resolved the single real conflict (content/docs/permissions/system-context.mdx, MIXED-routed to the os-regen driver) between head 889e30d31 and origin/main (578452681), per the driver's own printed recipe rather than --fix. `comm` of both sides' changed files confirmed exactly one overlapping path. Diffing base vs our branch's own copy of the file first showed our branch changed ONLY row 37's anchor line numbers (mechanical, share-link-service.ts grew ~20 lines) and zero prose anywhere on the page; main's side carried a full prose rewrite of row 30 (the 2026-08-31 #13533 ruling) plus a prose addition to row 37 (documenting #14033: resolution is not bypassed). Resolution: took main's side (theirs) entirely for the conflict block (rows 30-37, prose and anchors), except row 37's file:line anchors, where I kept our branch's numbers (469/523/527/600/630) instead of main's stale ones (449/503/507/580/610), since our branch -- not main -- is the one that actually moved share-link-service.ts. Committed the merge first (43d3b2aad), then off MERGE state ran `pnpm gen:system-context-census` as the discharge step -- it rewrote 0 anchors, confirming the hand-picked numbers were already exactly correct against the real merged source, so no separate discharge commit was created. Pushed 889e30d31->43d3b2aad; GitHub's mergeable_state moved off `dirty`; CI started on the new head (confirmed both by me immediately post-push -- 16 check runs in_progress/queued -- and independently by the coordinator afterward -- 19+ success, zero failures). No card implementation was touched.",
"tests": "Proof no prose was dropped: normalised both the merged copy and origin/main's copy of the file through the driver's own `blankAnchorLineNumbers` comparator (blanks only the trailing digits in `file:line` citations) -- byte-identical after normalisation; the raw diff shows exactly one line differing, and only in anchor numbers (independently reconfirmed by the coordinator via a `sed`-based normalisation, also 0-line prose diff). `pnpm check:system-context-census` on the merged tree -- verdict: `check-system-context-census: OK — 106 elevation read sites in 20 packages across 45 files, all anchored; 140 anchors resolve, 27 declared non-read.` Supplementary, already in hand before the coordinator's stand-down (not further work): `pnpm --filter @objectstack/plugin-sharing test` completed -- `Test Files 32 passed (32)`, `Tests 798 passed (798)`; the `@objectstack/runtime` suite and the package typechecks were mid-run under the shared verify lock when told to stand down, and were cancelled (TaskStop) rather than left running, since CI on the merged head is the stronger, already-clean authority for that question and the local run was redundant with it.",
"mcp_calls": 6,
"open_questions": [
{
"question": "Re-deriving the gate family at the merged head (`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`, run once, not re-verified before stand-down) reported 67 commands (44 pnpm + 23 direct node) against the same 8-path changeset the PR body already lists -- versus the PR body's own claim of \"62 families, unchanged from the previous head\" taken at the pre-merge head 889e30d31. I did not execute the derived list (stood down before running it) and cannot say whether this is a real gate-list change introduced by main's movement (a new check script landing among the 361 main-side files) or just a different counting convention (individual commands vs. grouped families) between the two dispatch-gates.mjs invocations.",
"options": [
"A. Treat as a counting-convention artifact (families vs. commands) and take no action -- CI's `Lint & Repo Gates` job runs the real family regardless and is already green/in-progress with no failures.",
"B. Re-run `dispatch-gates.mjs --commands` once more (cheap, no lock needed) and diff its output against the PR body's list by name to confirm whether any command is actually new."
],
"recommendation": "A, because CI on the merged head is already running the authoritative family and reporting no failures; a local re-derivation buys confirmation of a number, not a finding, and the coordinator's stand-down already established that CI is the stronger authority for this round."
}
],
"out_of_scope_findings": []
}Generated by Claude Code Generated by Claude Code |
Merge round ACCEPTed — and the resolution made a subtler call than "take main's side"
The call that mattersOne real conflict,
The resolution took main's side entirely for the conflict block — except row 37's That is the right call and it is not the obvious one. Taking "theirs" wholesale — the reflex when main carries newer prose — would have reverted the anchors to numbers that no longer match the merged source, reintroducing exactly the line rot the census gate exists to catch. Newer prose and correct anchors came from different sides, and the resolution split them accordingly. The verification that proves it, rather than asserting itThe merge was committed first (
No prose dropped, established two independent ways: the dev normalised both copies through the driver's own
On the stand-downI cut the remaining local sweep because CI on the merged head runs the same suites in a clean environment and is the stronger authority post-push; holding the contended heavy-verify lock (#14944) for a redundant run starves work that isn't. The dev cancelled the in-flight batch rather than abandoning it to run on, which is the right way to comply. The open question, disposed: AThe re-derivation at the merged head reported 67 commands (44 pnpm + 23 direct node) against the PR body's "62 families" at the pre-merge head, and the dev correctly flagged that it could not tell a real change from a counting-convention difference without running it. Take A — no action. The two figures are in different units: LandingCarriers were cleared earlier on the director-tier PASS; the PR is non-draft. CI on Generated by Claude Code |
Landing —
|
Fixes #14637
Gates the share-link route probe on the object's standing
publicSharing.enabledpolicy, at both probe sites, per the maintainer's ruling of 2026-09-03
(decision batch #17, item 1 — verbatim 「同意」, adopting option A; recorded on
14637#issuecomment-5522728999). Option B (keep the 401 and document the accepted
oracle) is the recorded fallback and is not taken; option C (gate only the two 401
arms) is rejected as proliferation.
The defect
resolveTokenrefuses a link whose object haspublicSharing.enabledoff, andrefuses it with the undifferentiated
nullthat revoked, expired, unknown andineligible tokens all get — because, in that gate's own prose, for a caller who
may hold nothing but a token, a distinguishable "sharing is off for this object"
is an existence oracle.
The HTTP layer above it re-opened exactly that oracle. Both share-link surfaces
run a row probe after
resolveTokenreturns null, and both answered from thesys_share_linkrow with no knowledge of the object's block. So an anonymouscaller could still tell a real-but-switched-off token from an unknown one:
password_hashdrew401 NEEDS_PASSWORD, and with any password401 WRONG_PASSWORD— including a correct password, which is both an oracleand a lie, since that link can serve nothing;
audience: 'signed_in'drew401 SIGN_IN_REQUIRED;410 EXPIRED_OR_REVOKED.A security property stated in one layer and defeated in the layer above it is
worse than one never claimed, because the next reader believes the comment.
The change, at both sites
origin/mainf116b8fpackages/plugins/plugin-sharing/src/share-link-routes.tspackages/runtime/src/domains/share-links.tsBoth probes read the policy before they answer from the row; when the block is
off every arm falls through to the generic
404 INVALID_OR_EXPIRED. Theruntime file is the dispatcher twin and, for cloud's per-environment kernels, the
designed primary surface (
registerShareLinkRoutes: false), so landing at onesite alone would have moved the oracle rather than closed it — which the ablation
below measures rather than asserts.
Two supporting shapes:
isPublicSharingEnabled(schema)is exported fromshare-link-service.ts(line 103) and
getPolicynow delegates to it, so the route asks the service'sown question instead of spelling a second
publicSharing.enabledread. It isnot added to the package's public
index.ts— nothing outside the packageneeds it.
packages/runtimecarries a documented local mirror because@objectstack/plugin-sharingis a dev dependency there; importing it wouldinvert the dependency direction for one boolean. The proper shared home is a
member on the
IShareLinkServicecontract inpackages/spec, which this cardcannot open (single-owner, another lane) — filed as spec: give
publicSharing.enabledone canonical predicate; retire thepackages/runtimemirror (#14637 follow-up) #14935, and deliberatelynot implemented here.
invalidOrExpiredclosure, so the gated arms are byte-identical to the unknown-token answer by
construction rather than by three copies staying in step.
Fail-closed on an unreadable policy is deliberate: an object whose schema the
engine cannot answer for is
enabled: falsebygetPolicy's definition, which isthe answer
resolveTokenandcreateLinkalready reach.Pins
Both sites, both 401 shapes, both halves of the 410 arm and the
unreadable-policy case — 5 cases per site, each asserting byte-equality with
the unknown-token answer (
toEqualon the whole captured answer andJSON.stringifyequality, which also pins key order) rather than merely "a 404",and each carrying its reverse check that with the block ON the 401s, the 410 and
the 200 are exactly what they were.
packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts:1155—driven through the real
registerShareLinkRouteson the realShareLinkServiceover a live
SqlDriveron better-sqlite3:memory:, with the route's securedefault context (every request anonymous).
mountResolveRoutegained twooptional widenings (a verified signed-in id; a request query), both inert for
every caller that predates them.
packages/runtime/src/domains/share-links-enforcement-context.test.ts:727—driven through the production
handleShareLinksRequestwith the realShareLinkServiceand the real ADR-0112 envelope builder, over that file'salready-pinned storage double.
makeEnginegained an optional schema map so atest can flip the block and take the schema away; omitted, its
getSchemaisbyte-for-byte what it was.
The 410 arm is reached by two predicates, not one —
row.revoked_at || (row.expires_at && Date.parse(row.expires_at) <= Date.now())—so a pin on the revoked half alone would leave the expired half free to keep
answering 410 on a switched-off object: the same oracle, reached by the other
predicate, and invisible to every assertion in the file. Both sites now pin the
expired half too. Expiry is stamped on the stored row rather than minted:
createLinkrefuses a pastexpiresAtoutright with422 EXPIRY_IN_PAST, soback-dating the row is the only way to reach an already-expired link — which is
what the passage of time does to a live one, and the same stamp the file's
existing pins already use.
Ablation — direction predicted before the run
Measured at
261cfb8d8. Predicted: reverting either site's gate reddensonly that site's pins, and leaves the other site green. Each leg reverts one
file to the branch point, proves the mutation reached disk by literal-text
counts, runs both suites, restores with
git checkout HEAD --and proves therestore by blob-hash equality plus an empty
git diff HEAD.share-link-routes.tsisPublicSharingEnabled3 to 0,invalidOrExpired3 to 0Tests 5 failed | 37 passed (42)Tests 17 passed (17)runtime/.../share-links.tsisPublicSharingEnabled3 to 0,invalidOrExpired3 to 0Tests 4 failed | 13 passed (17)Tests 42 passed (42)Exactly as predicted, and the fifth red in leg A is the pre-existing
[#14033] at the HTTP seamcase, whose amended tail also depends on the gate.Both files restored to their head blobs (
03632ed7…,d3cfa4aa…) with an emptygit diff HEAD. Neither mutated file is resolved through a packageexportsmap— both are imported relatively by their own package's tests — so no rebuild sits
between the mutation and the reading, and the reds themselves are the proof the
mutated source was what ran.
The review round at
889e30d31changes no code path — the only non-proseedits are two added test cases and one corrected comment — so no new ablation
is owed for the gate itself, and none was run. Stated rather than silently
omitted. The legs above still describe the shipped gate: the reverted hunks are
byte-identical at both heads.
Two existing fixtures re-judged (both recorded, neither weakened)
share-link-eligibility.test.ts, the[#14033]HTTP-seam case. Itsrevoked-bucket assertion (
410, commented "this change does not move it")was measured after
switchOff, so what it actually pinned was the routeprobe answering from the row with the block off — the very arm option A
changes. The reading it recorded is preserved: it is now taken before the
switch, where "the revoked bucket is a different status and [Decision] What does turning
publicSharing.enabledoff mean for an ALREADY-MINTED share link? — the parent switch is mint-only while its own child predicate is now a standing policy #14033 does notmove it" is true, and the post-switch answer is pinned to the unknown-token
answer beside it. Coverage went up, not down.
share-link-envelope.conformance.test.ts. Its harness stubsSharingEnginewith nogetSchemaat all and its probe rows named noobject_name, so under the gate all four of its row arms fell through to thegeneric 404 — correctly and fail-closed, but the refusals whose envelope
that module exists to pin became unreachable. The double now declares the block
for the object its rows name, and the rows name it. A fixture declaration, not
a relaxation; the gated behaviour is pinned in the file above.
Changeset
.changeset/share-link-probe-policy-gate.md— minor on both@objectstack/plugin-sharingand@objectstack/runtime, carrying the**BREAKING**banner. Justification: this is a breaking runtime behaviour changeon a published HTTP path (a refusal status changes for two link shapes and both
halves of the revoked/expired arm), with no published export added, removed or
re-shaped. That is the same judgement and the same wording the sibling ruling took
in
.changeset/share-link-enabled-at-redemption.md(#14033) —minorunder therepo's launch-window convention, where a breaking change does not burn a major
while the stack is in lockstep. Its ADR-0087 disposition is
not-required (no-migration-prescription), andcheck:adr-0087-registrationaccepts it. Its "Consumer impact" paragraph now names both consumer-visible
shifts, with the measured consumer and its line range — see the next section.
Note for the
repo:objectuiseat — the consequence, measured; still no work owedThe triage seat's recorded confidence gap was that the viewer might key its
password prompt on
401 NEEDS_PASSWORD, which option A changes. It does notkey on the code — it keys on the status, and the consequence is real.
An earlier revision of this section reported a grep of objectui
packages/forthe five error-code names (
NEEDS_PASSWORD,WRONG_PASSWORD,SIGN_IN_REQUIRED,EXPIRED_OR_REVOKED,INVALID_OR_EXPIRED) returning zero,and concluded "no consumer found". That conclusion was wrong. The zero itself
reproduces — re-run at objectui
67dadd6across bothapps/andpackages/, still zero — but it answers a different question than the one asked:the consumer lives in
apps/, and it never reads the body's code.apps/console/src/pages/SharedRecordPage.tsxlines 70-85 dispatch onres.statusalone:401, any codesetNeedsPassword(true)— the password prompt404410So for a share link on an object whose
publicSharing.enabledis off, thisPR changes the console in two ways:
NEEDS_PASSWORD,WRONG_PASSWORDandSIGN_IN_REQUIRED— rendered the password prompt and now render the 404copy, "This link is invalid or no longer available.";
renders that same 404 copy.
Both are the accepted consequence of the maintainer's option A, not a
regression: a correct password on such a link yields nothing, and "expired or
revoked" confirms to the holder that the token was real — the existence oracle
the ruling closed. No objectui change is owed and none is made here. The
ruling delegated recording this to the PR body for the objectui seat, so this
section is the carrier, and it now carries the consequence rather than its
negation.
Three supporting readings, each confirmed rather than recalled:
checklist —
docs/qa/platform-checklist/areas/access-security.json:2737, onitem
access-security.share-link-landing-page: "the page folds EVERY 401 intothe password prompt (SharedRecordPage.tsx branches on status alone, never on the
body's NEEDS_PASSWORD vs SIGN_IN_REQUIRED code)". That is independent
corroboration of the branching, recorded before this card existed.
link first, and
createLinkrefuses a non-system caller on a switched-offobject with
422 SHARING_NOT_ENABLED(share-link-service.ts:469-475), so theitem's runs are all on objects whose block is ON — where nothing moves. The
changed rendering is reachable only when the block is switched off after
minting, which is the standing-policy scenario [Decision] What does turning
publicSharing.enabledoff mean for an ALREADY-MINTED share link? — the parent switch is mint-only while its own child predicate is now a standing policy #14033 established.packages/components/src/share/ShareDialog.tsx(authenticated create / list /revoke, no resolve probe), and
packages/app-shell/src/hooks/useChatConversation.ts, which readsGET /share-links/:token/messages— a route with no row probe at all(
share-link-routes.ts:355-367: a nullresolveTokenanswers404 NOT_FOUNDunconditionally), so its refusal is unchanged.
Recorded, not fixed
Two observations from the contract review, both deliberately left alone:
The gate calls
getSchemaon the engine handle the probe already holds.getSchemais declared non-throwing; an occupant that throws from it escapesthe gate into the surface's generic thrown-error exit
(
share-links.ts:361,deps.errorFromThrown(err, 500)). Closing that wouldmean writing a tolerant consumer around a contract violation — the shape this
repo's contract-first rule sends back to the producer instead. Every double in
these suites and the real engine satisfy the contract.
getEngine()'s host/scoped divergence is inherited, not widened.share-links.ts:145-151prefers the request kernel'sobjectqland falls backto environment-scoped resolution, so the two can in principle be different
occupants. The gate adds no new resolution: it reads the schema through the
same
enginehandle the row probe already resolved on the line above it, sothe divergence surface is exactly what it was before this PR.
Verification — all at head
889e30d31pnpm --filter @objectstack/plugin-sharing test—Test Files 30 passed (30),Tests 731 passed (731)(730 before the new pin).pnpm --filter @objectstack/runtime test—Test Files 214 passed (214),Tests 3127 passed (3127)(3126 before the new pin).pnpm --filter @objectstack/plugin-sharing --filter @objectstack/runtime typecheck— exit 0, both packages.
check:test-typecheckreports OK for both, so theadded test cases are inside their package's
tsconfig.test.jsonprogram (thepackage
typecheckalone does not read test files — recorded so the green isnot over-read).
pnpm lint(eslint . --no-inline-config, the whole repo) — exit 0.pnpm check:nul-bytes,pnpm check:partof-closing-keyword,pnpm check:agent-model-declared— exit 0.The gate family re-derived at this commit by
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands— 62 families, unchanged from the previous head (this round added no new
path to the change set). Every exit code captured by redirect-then-read, never
across a pipe. 58 green; 4 recorded as NOT MEASURED, each because the gate
refuses to measure rather than measure wrong, and each needing a repo-wide build
or a saved run log this worktree does not have:
check-test-completeness.mjsturbo run testlogcheck:dual-build-cjs-loadscheck:i18ncheck:type-check-debtThis is a declared narrowing, not a silent omission: CI runs the farm with a
full build regardless, and none of the four can be moved by this round's diff —
one comment line, two added test cases and one changeset paragraph change no
package export, no emitted
dist/, and no translation key.check:skill-exampleshit the same wall on the first pass, was cleared by building
@objectstack/client-reactand its closure, and re-ran green(
256 prose examples type-check across 3 surfaces).check:type-check-coverage(the sibling that does not need the closure) is green.
dispatch-gates.mjsprints a STALE TREE warning: this branch is at least 35commits behind
origin/main, and 53 of the files the families derive fromchanged across that range. The family list is nonetheless identical to the one
the previous head derived, and
mainwas deliberately not merged this round —the branch stays at its point so the contract-review delta is small to re-read.
Status
Draft, and carrying
needs:contract-review: the ruling setsClause-②: yesbecause a published HTTP path changes its refusal for two link shapes. A
tier-gated contract review returned PASS WITH REQUIRED PATCHES and the
required patches are now landed — the false objectui conclusion rewritten above,
the dangling test-file citation in the runtime mirror's docblock corrected to
share-links-enforcement-context.test.ts(git ls-tree -rhad zero entries forthe name it cited), plus the two non-blocking notes folded in (the changeset's
consumer paragraph, and the 410 arm's expired half). This PR is not to be
undrafted or armed on CI colour alone; the review seat re-reads this delta first.
🤖 Generated with Claude Code
https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Generated by Claude Code