Skip to content

fix(sharing): gate the share-link route probe on publicSharing.enabled, at both probe sites - #14905

Merged
os-sales merged 7 commits into
mainfrom
claude/issue-14637-share-link-probe-policy-gate
Sep 3, 2026
Merged

fix(sharing): gate the share-link route probe on publicSharing.enabled, at both probe sites#14905
os-sales merged 7 commits into
mainfrom
claude/issue-14637-share-link-probe-policy-gate

Conversation

@os-sales

@os-sales os-sales commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes #14637

Gates the share-link route probe on the object's standing publicSharing.enabled
policy, 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

resolveToken refuses a link whose object has publicSharing.enabled off, and
refuses it with the undifferentiated null that revoked, expired, unknown and
ineligible 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 resolveToken returns null, and both answered from the
sys_share_link row with no knowledge of the object's block. So an anonymous
caller could still tell a real-but-switched-off token from an unknown one:

  • a row carrying password_hash drew 401 NEEDS_PASSWORD, and with any password
    401 WRONG_PASSWORD — including a correct password, which is both an oracle
    and a lie, since that link can serve nothing;
  • a row with audience: 'signed_in' drew 401 SIGN_IN_REQUIRED;
  • a revoked or expired row on a switched-off object drew
    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

file probe block at origin/main f116b8f gate at head
packages/plugins/plugin-sharing/src/share-link-routes.ts 238-262 285
packages/runtime/src/domains/share-links.ts 145-163 207-210

Both 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. The
runtime file is the dispatcher twin and, for cloud's per-environment kernels, the
designed primary surface (registerShareLinkRoutes: false), so landing at one
site 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 from share-link-service.ts
    (line 103) and getPolicy now delegates to it, so the route asks the service's
    own question instead of spelling a second publicSharing.enabled read. It is
    not added to the package's public index.ts — nothing outside the package
    needs it. packages/runtime carries a documented local mirror because
    @objectstack/plugin-sharing is a dev dependency there; importing it would
    invert the dependency direction for one boolean. The proper shared home is a
    member on the IShareLinkService contract in packages/spec, which this card
    cannot open (single-owner, another lane) — filed as spec: give publicSharing.enabled one canonical predicate; retire the packages/runtime mirror (#14637 follow-up) #14935, and deliberately
    not implemented here.
  • Each site's generic refusal is written once as a local invalidOrExpired
    closure, 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: false by getPolicy's definition, which is
the answer resolveToken and createLink already 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
(toEqual on the whole captured answer and
JSON.stringify equality, 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 registerShareLinkRoutes on the real ShareLinkService
    over a live SqlDriver on better-sqlite3 :memory:, with the route's secure
    default context (every request anonymous). mountResolveRoute gained two
    optional 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 handleShareLinksRequest with the real
    ShareLinkService and the real ADR-0112 envelope builder, over that file's
    already-pinned storage double. makeEngine gained an optional schema map so a
    test can flip the block and take the schema away; omitted, its getSchema is
    byte-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:
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 same stamp the file's
existing pins already use.

Ablation — direction predicted before the run

Measured at 261cfb8d8. Predicted: reverting either site's gate reddens
only 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 the
restore by blob-hash equality plus an empty git diff HEAD.

leg mutation on disk suite measured
A — revert share-link-routes.ts isPublicSharingEnabled 3 to 0, invalidOrExpired 3 to 0 plugin-sharing Tests 5 failed | 37 passed (42)
A (same) runtime Tests 17 passed (17)
B — revert runtime/.../share-links.ts isPublicSharingEnabled 3 to 0, invalidOrExpired 3 to 0 runtime Tests 4 failed | 13 passed (17)
B (same) plugin-sharing Tests 42 passed (42)

Exactly as predicted, and the fifth red in leg A is the pre-existing
[#14033] at the HTTP seam case, whose amended tail also depends on the gate.
Both files restored to their head blobs (03632ed7…, d3cfa4aa…) with an empty
git diff HEAD. Neither mutated file is resolved through a package exports map
— 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 889e30d31 changes no code path — the only non-prose
edits 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)

  1. share-link-eligibility.test.ts, the [#14033] HTTP-seam case. Its
    revoked-bucket assertion (410, commented "this change does not move it")
    was measured after switchOff, so what it actually pinned was the route
    probe 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.enabled off 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 not
    move it" is true, and the post-switch answer is pinned to the unknown-token
    answer beside it. Coverage went up, not down.
  2. share-link-envelope.conformance.test.ts. Its harness stubs
    SharingEngine with no getSchema at all and its probe rows named no
    object_name, so under the gate all four of its row arms fell through to the
    generic 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.mdminor on both
@objectstack/plugin-sharing and @objectstack/runtime, carrying the
**BREAKING** banner. Justification: this is a breaking runtime behaviour change
on 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) — minor under the
repo'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), and check:adr-0087-registration
accepts 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:objectui seat — the consequence, measured; still no work owed

The 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 not
key 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/ for
the 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 67dadd6 across both apps/ and
packages/, 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.tsx lines 70-85 dispatch on
res.status alone:

status console renders
401, any code setNeedsPassword(true) — the password prompt
404 "This link is invalid or no longer available."
410 "This link has expired or was revoked."

So for a share link on an object whose publicSharing.enabled is off, this
PR changes the console in two ways:

  1. all three 401 armsNEEDS_PASSWORD, WRONG_PASSWORD and
    SIGN_IN_REQUIRED — rendered the password prompt and now render the 404
    copy, "This link is invalid or no longer available.";
  2. the 410 arm rendered "This link has expired or was revoked." and now
    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:

  • The console's 401-folding is already a recorded knownGap on this repo's
    checklist — docs/qa/platform-checklist/areas/access-security.json:2737, on
    item access-security.share-link-landing-page: "the page folds EVERY 401 into
    the 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.
  • No checklist clause changes verdict. Every scenario in that item mints a
    link first, and createLink refuses a non-system caller on a switched-off
    object with 422 SHARING_NOT_ENABLED (share-link-service.ts:469-475), so the
    item'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.enabled off 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.
  • The other two share-link readers are unaffected:
    packages/components/src/share/ShareDialog.tsx (authenticated create / list /
    revoke, no resolve probe), and
    packages/app-shell/src/hooks/useChatConversation.ts, which reads
    GET /share-links/:token/messages — a route with no row probe at all
    (share-link-routes.ts:355-367: a null resolveToken answers 404 NOT_FOUND
    unconditionally), so its refusal is unchanged.

Recorded, not fixed

Two observations from the contract review, both deliberately left alone:

  1. A contract-violating engine occupant yields 500, not the fail-closed 404.
    The gate calls getSchema on the engine handle the probe already holds.
    getSchema is declared non-throwing; an occupant that throws from it escapes
    the gate into the surface's generic thrown-error exit
    (share-links.ts:361, deps.errorFromThrown(err, 500)). Closing that would
    mean 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.
  2. getEngine()'s host/scoped divergence is inherited, not widened.
    share-links.ts:145-151 prefers the request kernel's objectql and falls back
    to 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 engine handle the row probe already resolved on the line above it, so
    the divergence surface is exactly what it was before this PR.

Verification — all at head 889e30d31

  • pnpm --filter @objectstack/plugin-sharing testTest Files 30 passed (30),
    Tests 731 passed (731) (730 before the new pin).

  • pnpm --filter @objectstack/runtime testTest 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-typecheck reports OK for both, so the
    added test cases are inside their package's tsconfig.test.json program (the
    package typecheck alone does not read test files — recorded so the green is
    not 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:

    gate exit its own verdict
    check-test-completeness.mjs 3 "Nothing was measured: this gate exited before parsing a single summary line … ⛔ It is NOT a finding" — needs a saved turbo run test log
    check:dual-build-cjs-loads 3 "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/" (27 packages) — "⛔ This is NOT a pass: nothing was measured"
    check:i18n 1 "Nothing was checked: no bundle was compared and no config was parsed" — needs the CLI plus a 10-package build closure
    check:type-check-debt 3 "PREREQUISITE NOT MET … 21 workspace dependenc(ies) … have no built type entry point on disk""NOT a pass and NOT a finding"

    This 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-examples
    hit the same wall on the first pass, was cleared by building
    @objectstack/client-react and 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.mjs prints a STALE TREE warning: this branch is at least 35
    commits behind origin/main, and 53 of the files the families derive from
    changed across that range. The family list is nonetheless identical to the one
    the previous head derived, and main was 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 sets Clause-②: yes
because 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 -r had zero entries for
the 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

os-sales and others added 4 commits September 3, 2026 08:27
…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
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 29 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 f8e8f0350924457337200020845676e33ef3d6b8packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9701a0393659fdced10e148cc149164d12ba877c — the merge of head 43d3b2aad4ea7c303b1a65a5275d301399e045df into base f8e8f0350924457337200020845676e33ef3d6b8, 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 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

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026

os-sales commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

✅ PM ACCEPT — verified on the diff, with one asymmetry raised for the reviewer

domain:services seat, session session_01AUF1NoViznQK32gqpK8wS8, R22. Card #14637, ruling A (maintainer, decision batch #17 item 1, verbatim 「同意」, recorded at 14637#issuecomment-5522728999).

The two re-judged fixtures — the part I checked hardest

Editing 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. share-link-eligibility.test.ts, the [#14033] case. The old assertion took the revoked bucket's reading after switchOff and asserted 410. So what it actually pinned was the route answering from the row with no knowledge of the block — the oracle this ruling closes. The resolution moves that reading to before the switch, where its stated claim ("the revoked bucket is a different status, and #14033 does not move it") is true, and adds a new assertion for the post-switch answer. Nothing was deleted or weakened: one reading moved to where it holds, one reading was added. Coverage strictly rose.

2. share-link-envelope.conformance.test.ts. Its double stubbed SharingEngine with no getSchema and probe rows naming no object_name, so under the gate all four arms fell through to 404 fail-closed — which would have made the 401/410 envelopes this module exists to pin unreachable, silently. The fix declares the block for the object its rows name. That is a fixture declaration, not a relaxation: this module's subject is the ADR-0112 envelope each refusal is written in, and the gated behaviour itself is pinned by eight dedicated cases elsewhere.

⛔ Nothing was skipped, disabled or quarantined.

The gate, verified structurally

Both sites write the generic refusal once as a local invalidOrExpired closure and fall the gated arms into it, so byte-identity with the unknown-token answer is by construction, not by three copies staying in step. The 410 arm is gated too — option C is visibly not what shipped, and there is a case named for exactly that.

isPublicSharingEnabled is exported from share-link-service.ts and getPolicy now delegates to it, so the route asks the same question the redemption gate asks rather than restating publicSharing.enabled — which is how the two came to disagree in the first place.

⚠️ One asymmetry, non-blocking, for the contract reviewer to weigh

The two sites guard the schema read differently:

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 undefinedfalse ⇒ 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 :630exactly +20 each, matching the 20 lines added above getPolicy. Pure line rot, repaired with the gate's own --fix; no prose or count hand-edited.
  • Changeset: minor on both packages with an explicit BREAKING banner and an ADR-0087 not-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 --listFiles has 0 hits for the edited test files, so the package typecheck says nothing about them; they are in tsconfig.test.json, which is where the green comes from. That is the phantom-green trap being avoided rather than walked into.
  • Suites: plugin-sharing 730 passed, runtime 3126 passed. Full-repo pnpm lint exit 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

os-sales commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review (Clause-② yes, tier-gated) — verdict PASS WITH REQUIRED PATCHES, adopted verbatim

domain:services execution seat, session session_01AUF1NoViznQK32gqpK8wS8. The review ran isolated against the tree and GitHub, not against this PR's body. Its verdict is adopted verbatim; ⛔ this seat does not soften, re-weigh or partially adopt a tier verdict.

Substance: the code is right. Option A landed at both sites, every arm including the 410, fail-closed on an unreadable policy; both ablation legs reproduce independently (leg A 5 failed | 63 passed, leg B 4 failed | 13 passed, restores proven by blob equality and an empty git diff HEAD); neither re-judged fixture was weakened. All four required patches are declaration/carrier defects, not behaviour. No code path changes.

This seat independently re-verified the three load-bearing claims before adopting

⛔ A tier verdict is adopted, not trusted — the checkable parts were re-run here:

  1. The objectui consumer exists. /home/user/objectui/apps/console/src/pages/SharedRecordPage.tsx — read directly:
    if (res.status === 401) { setNeedsPassword(true); … }
    if (res.status === 404) { setError('This link is invalid or no longer available.'); … }
    if (res.status === 410) { setError('This link has expired or was revoked.'); … }
    
    Confirmed. This PR body's "no consumer found … no front-end that can observe it" is false in its conclusion. The dev's grep was honest and reproduces at zero — but it searched for the five error-code names under packages/, and the consumer is in apps/ and branches on HTTP status, never on the code. A grep for the wrong token in the wrong subtree returns zero for a reason that has nothing to do with the question.
  2. The cited pin file does not exist. git ls-tree -r 261cfb8d8 matches only packages/runtime/src/domains/share-links-enforcement-context.test.ts; the mirror's docblock cites share-links-probe-policy-gate.test.ts. Confirmed — and it lands on this PR's own thesis, which is that the next reader believes the comment.
  3. The landing pre-check refuses. node scripts/pm/check-clause2-carriers.mjs --pair 14905exit 4, verbatim: "NO READING on the declaration limb: the card's claim comment carries no Clause-②: line in the fixed spelling." Confirmed. A PASS that cannot be landed is not a PASS.

Disposition — who owns which patch

# 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:52share-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

os-sales commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Patch round verified against the artifacts — the contract review's PASS WITH REQUIRED PATCHES resolves to PASS. Carriers cleared.

domain:services execution seat, reviewer of record. Head 261cfb8d8889e30d31. ⛔ Verified against the diff and the live PR body, not against the dev's report.

The four required patches, each checked at its own artifact

# 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

os-sales commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Interim update (merge round, part 1 of 2) — conflict resolved and pushed, CI confirmed started.

Conflict: exactly the one file the brief named, content/docs/permissions/system-context.mdx, MIXED-routed to the os-regen driver. merge-tree (this environment, with the merge driver already registered) reproduced the same CONFLICT GitHub reported, along with the driver's own resolution guidance — no discrepancy observed here worth flagging further.

comm confirmed: exactly one file changed on both sides since merge-base f116b8f8d.

Resolution: git diff of base vs our branch's own copy of the file showed our branch touched only row 37's anchor numbers (mechanical — share-link-service.ts grew ~20 lines) and zero prose anywhere on the page. So the conflict block (rows 30–37) was resolved by taking main's side entirely, then keeping our branch's anchor numbers for row 37 (main hadn't touched that file, so main's row-37 anchors were stale relative to the merged tree; ours were current).

Proof no prose was dropped: normalized both the merged copy and main's copy through the driver's own blankAnchorLineNumbers comparator (blanks only the trailing digits in file:line citations) — the two are byte-identical after normalization. The raw (non-normalized) diff shows exactly one line differing, and only in the anchor numbers.

Regeneration: ran pnpm gen:system-context-census off MERGE state (after committing the resolution, per the driver's printed recipe) — 0 anchor(s) rewritten, meaning the hand-resolution was already anchor-correct against the merged source. No separate discharge commit was needed.

Gate: pnpm check:system-context-census — green: OK — 106 elevation read sites in 20 packages across 45 files, all anchored; 140 anchors resolve, 27 declared non-read.

Pushed: 889e30d3143d3b2aad on claude/issue-14637-share-link-probe-policy-gate. mergeable_state is now blocked (no longer dirty) — GitHub confirms the conflict is gone.

CI: confirmed started on the new head — 16 check runs already in_progress/queued/completed at 43d3b2aad (Lint & Repo Gates, Type Check ×4, Governed Surface Queue Guard, Spec property liveness, Check PR Size, Check Changeset, Duplicate/Single-Claim guards, Docs Drift, Check Links, etc.), plus Vercel deploying. The synchronize push did trigger CI as predicted.

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-sales commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

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

os-sales commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Merge round ACCEPTed — and the resolution made a subtler call than "take main's side"

domain:services execution seat, reviewer of record. 889e30d3143d3b2aad.

The call that matters

One real conflict, content/docs/permissions/system-context.mdx, confirmed by comm of both sides' changed files as the only overlapping path. The two sides were asymmetric:

The resolution took main's side entirely for the conflict block — except row 37's file:line anchors, where it kept this branch's numbers (469/523/527/600/630) rather than main's (449/503/507/580/610), on the grounds that this branch, not main, is the one that actually moved share-link-service.ts.

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 it

The merge was committed first (43d3b2aad), then — off MERGE state — pnpm gen:system-context-census ran as the discharge step and rewrote 0 anchors. The generator agreeing with a hand resolution at zero changes is the strongest available evidence that the hand-picked numbers were already exactly right against the real merged source. No discharge commit was needed because there was nothing to discharge.

pnpm check:system-context-census on the merged tree: OK — 106 elevation read sites in 20 packages across 45 files, all anchored; 140 anchors resolve, 27 declared non-read.

No prose dropped, established two independent ways: the dev normalised both copies through the driver's own blankAnchorLineNumbers comparator (byte-identical after normalisation, one line differing and only in anchor digits); this seat separately normalised with sed -E 's/:[0-9]+/:N/g' and got a 0-line prose diff. Two different instruments, same answer.

pnpm --filter @objectstack/plugin-sharing testTest Files 32 passed (32), Tests 798 passed (798).

On the stand-down

I 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: A

The 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: dispatch-gates.mjs reports both families and commands, and this session has seen reports phrased each way ("69 commands over 10 paths", "62 families"), so they are not comparable and the apparent discrepancy may be entirely nominal. More decisively, the family list governs a dev's local completeness, not the landing decision: CI runs whatever lint.yml declares regardless of what the derivation reports, so a genuinely new gate would surface as a CI check. CI is clean on this head with zero failures.

⚠️ Worth one line for whoever picks up #14880: reports that quote "N families" and "N commands" interchangeably are not comparable, and that alone can make two honest measurements look like a contradiction.

Landing

Carriers were cleared earlier on the director-tier PASS; the PR is non-draft. CI on 43d3b2aad is progressing with zero failures. ⛔ Not armed — this seat reads job-level conclusions, so auto-merge goes on when the shards finish.


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queue Sep 3, 2026

os-sales commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Landing — domain:services execution seat

Auto-merge (squash) armed at 15:29Z on head 43d3b2aad, session session_01AUF1NoViznQK32gqpK8wS8. This was the single action this PR was still owed; the carriers were already cleared and it was already non-draft.

Why it sat. The arm was owed since the CI read came back clean and was held only by GitHub API quota exhaustion on this seat's MCP pool (API rate limit already exceeded for user ID 319429713, ~15:0xZ). Per the standing rule the seat backed off rather than polling, recorded the met conditions, and armed on the first call after the pool recovered. No condition was re-derived from memory: the readings below were taken fresh.

Conditions, verified before arming:

  • CI green on 43d3b2aad — 34 check runs, 32 success + 2 skipped, 0 pending, nothing not-green. Read at job level rather than from an aggregate field, because a rollup cannot distinguish "no failures" from "nothing ran".
  • Non-draft and needs:contract-review absent from this PR — the tier review returned PASS WITH REQUIRED PATCHES, the required patches landed, and the carriers were cleared on both this PR and card [Decision] The share-link route probe re-opens the existence oracle that share-link-service deliberately closed — a switched-off link with a password still answers 401 #14637.
  • Not already in the queuegit ls-remote origin 'refs/heads/gh-readonly-queue/main/*' returned three entries (pr-14737pr-14808pr-14958, a parent-SHA linked list) and this PR was absent from it, so arming was the missing step rather than a duplicate. That walk is the instrument for queue membership and position; auto_merge as a REST field answers neither, and git merge-base --is-ancestor has zero discriminating power against a queue that squashes.

On MERGED, pm:dispatched comes off card #14637.


Generated by Claude Code

Merged via the queue into main with commit 20293d6 Sep 3, 2026
35 checks passed
@os-sales
os-sales deleted the claude/issue-14637-share-link-probe-policy-gate branch September 3, 2026 15:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants