Skip to content

fix(plugin-sharing)!: the share-link routes emit the declared envelope, and the last ratchet retires (#3983) - #4037

Merged
os-zhuang merged 2 commits into
mainfrom
claude/envelope-drift-route-modules-v2zoky
Jul 30, 2026
Merged

fix(plugin-sharing)!: the share-link routes emit the declared envelope, and the last ratchet retires (#3983)#4037
os-zhuang merged 2 commits into
mainfrom
claude/envelope-drift-route-modules-v2zoky

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Closes #3983. The fifth and final drifting route module — guard goes 6 / 1 / 1 → 7 / 0 / 1.

Merge order is satisfied inside this PR. objectui#2980 is merged (96ee72e) and the .objectui-sha bump is a commit here, so the coupling is atomic — see Merge order.

This wasn't envelope cosmetics — two SDK methods were broken

Worth leading with, because #3983 framed this as "no body carries the success flag" and that undersells it.

Three of these routes are disposition: 'sdk' in runtime/src/route-ledger.ts, and ObjectStackClient.unwrapResponse decides a body is an envelope by finding a boolean success. With no flag it hands back the body verbatim:

method documented / typed as actually returned
shareLinks.create() "Returns the link row (incl. token)" { link: … } — so .token was undefined
shareLinks.list() Promise<any[]> { links: [] } — so .map() threw

And packages/client/src/admin-surfaces.test.ts mocks all three as { success: true, data: <payload> }. The SDK was written and tested against the dispatcher's shape, and only ever worked there.

So the target shape wasn't a design decision

runtime/src/domains/share-links.ts serves these same five paths, and for cloud's per-environment kernels it is the designed primary surface (registerShareLinkRoutes: false). It has always answered in the declared envelope with data carrying the payload directly. Same routes, two shapes, decided by which surface happened to mount them — the asymmetry #3636 fixed for /i18n, one domain over. This module converges onto the twin:

route was now
POST /share-links { link } { success: true, data: link }
GET /share-links { links } { success: true, data: link[] }
DELETE /share-links/:idOrToken { ok: true } { success: true, data: { ok: true } }
GET /:token/resolve { record, link, redactFields } { success: true, data: { … } }
GET /:token/messages { data: rows } { success: true, data: rows }
errors { error: { code, message } } { success: false, error: { … } }

data carries each payload baredata: links, not data: { links }. That is what makes unwrapResponse return the same value on both surfaces.

Two details worth calling out:

Consumer sweep

#3983 said this "needs its own consumer sweep". Done, and the blast radius is smaller than assumed — the framework has zero consumers of these routes (no dogfood proof drives them either).

In objectui, ShareDialog was already dual-shape tolerant on all three routes it calls — body.links ?? body.data, created.link ?? created.data, and revoke never reads the body. It needs no change, and the reason it was already tolerant is the point: both shapes genuinely exist in the fleet.

SharedRecordPage did need one fix, and it is exactly the kind a shape-swap would have missed. It renamed the wire's redactFields to the redactedFields its render reads — but only on the bare branch. The enveloped branch handed body.data through verbatim, so on the dispatcher path the "Some fields are hidden by the owner" notice never rendered, on exactly the pages where fields were being stripped. Converting this surface would have spread that to every share page. Fixed in objectui#2980 (merged).

Merge order

  1. objectui#2980 → merged as 96ee72e
  2. .objectui-sha bump + console rebuild → commit 3a67ab4 in this PR
  3. this PR → ready to merge

Step 2 lives here rather than in its own PR so the coupling is atomic: merging the conversion against a dist built from the old pin would ship enveloped resolve bodies to a page that skips the rename, i.e. it would cause the vanished-notice regression for one release window. check:console-sha confirms the rebuilt dist matches the new pin (packages/console/dist is gitignored, so only .objectui-sha and the generated changeset are committed).

I'm stating the order explicitly because I got the analogous claim wrong on #3972 ("the two repos are not coupled by merge order") — packages/console/dist is built from .objectui-sha, so they are.

Bonus fix: bump-objectui.sh rejected the mandated workflow

Doing step 2 surfaced a defect in the tool for it. The script guarded on -d "$OBJECTUI_ROOT/.git", but in a git worktree .git is a regular file holding a gitdir: pointer — so it rejected every linked worktree and only ever ran from a primary clone, while the Prime Directives require a per-task worktree in each repo a task spans. -e accepts both. Its error message also claimed the default sibling path even when OBJECTUI_ROOT was set explicitly, pointing the reader at the wrong thing; it now names the path it actually rejected.

Tests

The module had no test file at all — only share-link-service.test.ts beside it. It gains a driven conformance suite: 26 cases over all five routes and thirteen error branches, every body parsed against the real spec schemas rather than restatements of them, plus the unwrapResponse decision restated so the two broken SDK methods are pinned as working, and a sameShapeAsDispatcher block so the two surfaces cannot drift apart again (#3833's failure mode, one domain over).

I checked the suite isn't vacuous: reverting the two emitters to their pre-#3983 form fails 22 of the 26.

Guard

privateOk is narrowed to what its own doc comment always claimed — a literal ok at the top of a response body, where it competes with success. The same literal inside data is payload, which is what a conformant revoke returns; counting it was an over-broad approximation that was harmless only while no conformant module had one. Four self-test assertions pin both readings, and the narrowing can only lower counts, so the six already-conformant modules stay at privateOk: 0.

The ratchet section is now empty but the mechanism stays — it is how the next drifting module gets recorded honestly instead of being either fixed on the spot or quietly skipped.

Verification

gate result
pnpm check:route-envelope + --self-test 7 conformant / 0 ratcheted / 1 exempt
pnpm check:console-sha dist matches pin objectui@96ee72e85439
@objectstack/plugin-sharing 224 passed (11 files)
@objectstack/runtime 904 passed (65 files)
@objectstack/client 200 passed (14 files)
spec tsc --noEmit · check:docs · check:skill-refs · check:react-blocks all clean
check:error-code-casing · check:nul-bytes · check:role-word · check:org-identifier all clean
eslint --no-inline-config on changed files clean

Method note. The runtime suite first showed 1 failure in datasource-autoconnect.test.ts (expected ['first','second'], got each row 6×). It is not mine and not a product bug: a gitignored packages/runtime/.objectstack/data/memory-driver.json persists rows across local runs. I confirmed it by stashing my changes and reproducing on the pristine tree, then cleared the file and got 904/904 both with and without my diff. CI checks out fresh, so it never sees it. Recording the method because I misdiagnosed a stale-artifact failure as "reproduces on main" during #3972.

Deferred finding. #4038 — the dispatcher domain emits data and a legacy link/links beside it on create/list. That producer-side shim loses its last reader once this PR lands, but deleting it needs a cloud sweep, so it is filed rather than done here (Prime Directive #10).

…e, and the last ratchet retires (#3983)

The fifth and final drifting route module. Unlike the four in #3843 this one was
not found by reading — `scripts/check-route-envelope.mjs` surfaced it the moment
that scan went repo-wide, which is the argument for a repo-wide guard over
per-package copies. It also turned out to be the one where the drift had broken
shipped SDK methods, not merely mis-shaped a body.

Three of these routes are `disposition: 'sdk'` in `runtime/src/route-ledger.ts`,
and `unwrapResponse` decides a body is an envelope by finding a boolean
`success`. With no flag it returns the body verbatim, so against this surface
`shareLinks.create()` — documented "Returns the link row (incl. token)" — handed
back `{ link: … }` and `.token` was undefined, while `shareLinks.list()`, typed
`Promise<any[]>`, handed back `{ links: [] }` and any `.map()` on it threw.
`packages/client/src/admin-surfaces.test.ts` mocks all three as
`{ success: true, data: <payload> }`: the SDK was written and tested against the
DISPATCHER's shape and only ever worked there.

So the target shape was not a design decision. `runtime/src/domains/share-links.ts`
serves these same five paths — for cloud's per-environment kernels it is the
designed PRIMARY surface — and has always answered in the declared envelope with
`data` carrying the payload directly. This module now answers identically, which
is what makes `unwrapResponse` return the same value either way:

  POST   /share-links            { link }                    -> data: link
  GET    /share-links            { links }                   -> data: link[]
  DELETE /share-links/:id        { ok: true }                -> data: { ok: true }
  GET    /:token/resolve         { record, link, redact… }   -> data: { … }
  GET    /:token/messages        { data: rows }              -> data: rows
  errors { error: { code, msg } }                            -> + success: false

`{ ok: true }` survives on revoke, but as the payload rather than as the body: at
the top level it was a second word for `success` (#3689 retired that from
storage); under `data` it is what the dispatcher already returned. The error half
was already nested `{ code, message }` — #3675's changeset cited this module as
the good example — so only the flag is new there, and all eleven codes were
already SCREAMING_SNAKE and registered, so ADR-0112 needs nothing.

Consumers swept: the framework has ZERO. In objectui, ShareDialog was already
dual-shape tolerant on all three routes it calls, and carried that tolerance
precisely BECAUSE both shapes existed in the fleet. SharedRecordPage needed one
fix of the kind a shape-swap would have missed — it renamed the wire's
`redactFields` to `redactedFields` only on the bare branch, so on the enveloped
dispatcher path the "fields are hidden by the owner" notice never rendered.
Converting this surface would have spread that to every share page; fixed in
objectui#2980, which merges first.

The module had no test file at all (only `share-link-service.test.ts`), so it
gains a driven conformance suite: 26 cases over all five routes and thirteen
error branches, every body parsed against the real spec schemas, plus the
`unwrapResponse` decision restated so the two broken SDK methods are pinned as
working. Reverting the two emitters fails 22 of the 26.

Guard: 7 conformant / 0 ratcheted / 1 exempt, from 6 / 1 / 1. `privateOk` is also
narrowed to what its doc always claimed — a literal `ok` at the TOP of a body,
where it competes with `success`; the same literal inside `data` is payload, which
is what a conformant revoke returns. Four self-test assertions pin both readings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CYbS3kS8xzsHNXFTzp4e2z
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 30, 2026 5:39am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-sharing.

4 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/permissions/authorization.mdx (via packages/plugins/plugin-sharing)
  • content/docs/permissions/permissions-matrix.mdx (via packages/plugins/plugin-sharing)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-sharing)
  • content/docs/protocol/objectql/security.mdx (via packages/plugins/plugin-sharing)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Jul 30, 2026
…olve body meets a page that renames redactFields (#3983)

Step 2 of #3983's merge order, in the same commit range as the conversion so the
coupling is atomic: merging the envelope change against a console dist built from
the old pin would ship enveloped resolve bodies to a page that only renames
`redactFields` -> `redactedFields` on the BARE branch, so the "fields are hidden
by the owner" notice would vanish from every share page for one release window.
objectui#2980 fixes that and is the pin's new tip.

Also fixes `bump-objectui.sh` for the workflow AGENTS.md mandates. Its guard tested
`-d "$OBJECTUI_ROOT/.git"`, but in a git WORKTREE `.git` is a regular file holding a
`gitdir:` pointer — so the script rejected every linked worktree and only ever ran
from a primary clone, while the Prime Directives require a per-task worktree in each
repo a task spans. `-e` accepts both. The error message also claimed the default
sibling path even when OBJECTUI_ROOT was set explicitly, which pointed the reader at
the wrong thing; it now names the path it actually rejected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CYbS3kS8xzsHNXFTzp4e2z
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

Development

Successfully merging this pull request may close these issues.

share-link-routes.ts is the fifth drifting envelope module — no body carries success, and one answers the { ok: true } #3689 retired

2 participants