From 3186953c99a92adf253117d0ccb30a04c45a5e42 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 03:05:55 +0000 Subject: [PATCH 1/3] fix(plugin-sharing): report a refused resolveToken usage stamp once as a durability degradation `ShareLinkService.resolveToken` stamps `use_count` / `last_used_at` on `sys_share_link` after a successful resolution; the stamp's `catch` was empty, so a storage refusal froze both counters while the link kept resolving and the shipped `active_links` grid kept asserting them. The refusal is now reported through the service's existing `{ info?, warn, error? }` logger at `error` (guaranteed `warn` fallback), once per service instance, with the resolution itself unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --- ...share-link-usage-stamp-refusal-reported.md | 38 ++++ .../src/share-link-service.test.ts | 183 ++++++++++++++++++ .../plugin-sharing/src/share-link-service.ts | 82 +++++++- 3 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 .changeset/share-link-usage-stamp-refusal-reported.md diff --git a/.changeset/share-link-usage-stamp-refusal-reported.md b/.changeset/share-link-usage-stamp-refusal-reported.md new file mode 100644 index 0000000000..d361544f3c --- /dev/null +++ b/.changeset/share-link-usage-stamp-refusal-reported.md @@ -0,0 +1,38 @@ +--- +"@objectstack/plugin-sharing": patch +--- + +fix(plugin-sharing): a refused `use_count` / `last_used_at` stamp in `resolveToken` is reported as a durability degradation, once, instead of being swallowed (#12981, batch 9) + +`ShareLinkService.resolveToken` stamps `use_count` and `last_used_at` on +`sys_share_link` after every successful resolution. The stamp's `catch` was +empty ("usage telemetry is a nice-to-have"), so a storage refusal — a +read-only database, a missing table, a broken system-context write path — +left the link resolving normally while both counters silently froze. + +Those counters are a persistence CLAIM, not telemetry: `sys_share_link` +declares `use_count` as "Incremented by resolveToken on every successful +resolution" and `last_used_at` as "Stamped by resolveToken; used by the +dashboard to highlight active links", and the shipped `active_links` grid +lists both. After a swallowed refusal an administrator read a count the +system's own declaration defines, wrong, with no signal anywhere — the +AGENTS.md "Degradation log levels" shape (persisted state and runtime state +disagree while nothing looks broken). + +**What changed.** The refusal is now reported through the service's existing +`logger` option — the published `{ info?, warn, error? }` shape — at `error`, +falling back to the guaranteed `warn` channel when the host sink declares no +`error`. The line names the consequence (both counters are not being +persisted; links keep resolving; the `active_links` grid under-counts), the +fix (resolve the storage refusal named as the cause; refused stamps are not +replayed), and the cause. It is emitted **once per service instance**, at the +first refusal, never per request — `resolveToken` runs on every public +share-link request, and a line per refused stamp would be the flood the rule +forbids. + +**What did NOT change**, and is pinned: the resolution itself (the holder is +still served, `redactFields` is unchanged, `resolveToken` never throws for a +refused stamp); the success path (`use_count` still increments and +`last_used_at` is still stamped on every successful resolution); the public +HTTP projection; and `ShareLinkServiceOptions` — no member is added or +widened, so hosts compile exactly as before. diff --git a/packages/plugins/plugin-sharing/src/share-link-service.test.ts b/packages/plugins/plugin-sharing/src/share-link-service.test.ts index e7ec7a57eb..61061f06d1 100644 --- a/packages/plugins/plugin-sharing/src/share-link-service.test.ts +++ b/packages/plugins/plugin-sharing/src/share-link-service.test.ts @@ -607,3 +607,186 @@ describe('[#13856] declared redactFields survive publicSharing opt-out', () => { expect(caught.code).toBe('SHARING_NOT_ENABLED'); }); }); + +// [#12981, batch 9] The `use_count` / `last_used_at` stamp at the end of +// `resolveToken` used to be swallowed by an empty `catch` ("usage telemetry is +// a nice-to-have"). It is a durability site: `sys_share_link` DECLARES both +// counters as written by `resolveToken`, and the shipped `active_links` grid +// asserts them — so a refused stamp left an admin grid asserting a number the +// system's own declaration defines, wrongly, with no signal. The repair reports +// the refusal through the service's existing `{ info?, warn, error? }` sink at +// `error` (falling back to the guaranteed `warn`), ONCE per instance, and +// leaves the resolution itself untouched. +describe('[#12981] a refused usage stamp is reported ONCE as a durability degradation', () => { + /** A sink that records every call, per level, so counts are exact. */ + function makeSink() { + const calls = { error: [] as any[][], warn: [] as any[][], info: [] as any[][] }; + return { + calls, + logger: { + info: (...a: any[]) => { calls.info.push(a); }, + warn: (...a: any[]) => { calls.warn.push(a); }, + error: (...a: any[]) => { calls.error.push(a); }, + }, + }; + } + + /** + * An engine whose `sys_share_link` UPDATE is refused with `err` for as long + * as `refusing.on` is true — every other operation (find / insert / the + * record probe) is the plain fake, so the ONLY thing that fails is the stamp. + */ + function makeRefusingEngine(err: unknown) { + const base = makeFakeEngine(SCHEMAS); + base._tables.ai_conversations = [{ id: 'c1', title: 'Demo' }]; + const refusing = { on: true }; + const engine = { + ...base, + async update(object: string, idOrData: any, dataOrOptions?: any) { + if (refusing.on && object === 'sys_share_link') throw err; + return base.update(object, idOrData, dataOrOptions); + }, + }; + return { base, engine, refusing }; + } + + async function mint(service: ShareLinkService) { + return service.createLink( + { object: 'ai_conversations', recordId: 'c1', audience: 'link_only', permission: 'view' }, + { userId: 'u1' }, + ); + } + + const REFUSAL = Object.assign(new Error('SQLITE_READONLY: attempt to write a readonly database'), { + code: 'STORAGE_REFUSED', + }); + + it('positive — the link still resolves, and the refusal is reported at error naming both counters', async () => { + const { engine, base } = makeRefusingEngine(REFUSAL); + const sink = makeSink(); + const service = new ShareLinkService({ engine: engine as any, logger: sink.logger }); + const link = await mint(service); + + const resolved = await service.resolveToken(link.token); + + // The resolution is UNCHANGED by the refusal: the holder is served. + expect(resolved).not.toBeNull(); + expect(resolved!.link.id).toBe(link.id); + expect(resolved!.redactFields).toEqual(['metadata']); + // ...and the counters genuinely did not move — the thing being reported. + expect(base._tables.sys_share_link[0].use_count).toBe(0); + expect(base._tables.sys_share_link[0].last_used_at).toBeNull(); + + // The report: exactly one, at `error`, not degraded to `warn` while + // `error` is available. Consequence and fix in the one line, plus the + // cause, per AGENTS.md → "Degradation log levels". + expect(sink.calls.error).toHaveLength(1); + expect(sink.calls.warn).toHaveLength(0); + expect(sink.calls.info).toHaveLength(0); + const [message, meta] = sink.calls.error[0]; + expect(message).toContain('use_count'); + expect(message).toContain('last_used_at'); + expect(message).toContain('sys_share_link'); + expect(message).toContain('active_links'); + expect(message).toContain('Fix:'); + expect(message).toContain('SQLITE_READONLY: attempt to write a readonly database'); + expect(meta).toMatchObject({ + link: link.id, + object: 'ai_conversations', + record: 'c1', + reason: 'STORAGE_REFUSED', + }); + }); + + // ⭐ The "say it ONCE" pin. `resolveToken` runs on every public request, so a + // line per refused stamp is the flood the rule forbids. N = 5 ≥ 3. + it('say it ONCE — five consecutive refused stamps produce exactly one report', async () => { + const { engine } = makeRefusingEngine(REFUSAL); + const sink = makeSink(); + const service = new ShareLinkService({ engine: engine as any, logger: sink.logger }); + const link = await mint(service); + + for (let i = 0; i < 5; i++) { + // Every resolution still serves — the degradation never leaks to the holder. + expect(await service.resolveToken(link.token), `resolution #${i + 1}`).not.toBeNull(); + } + + expect(sink.calls.error).toHaveLength(1); + expect(sink.calls.warn).toHaveLength(0); + expect(sink.calls.error[0][0]).toContain('Reported ONCE'); + }); + + // Reverse control. Without it, "once" and "never" are indistinguishable: a + // reporter that never fires also passes the pin above only through the + // positive test, so the control pins the OTHER direction — a stamp that + // lands produces nothing at any level. + it('reverse control — five stamps that LAND produce zero output at every level', async () => { + const { engine, refusing, base } = makeRefusingEngine(REFUSAL); + refusing.on = false; + const sink = makeSink(); + const service = new ShareLinkService({ engine: engine as any, logger: sink.logger }); + const link = await mint(service); + + for (let i = 0; i < 5; i++) { + expect(await service.resolveToken(link.token)).not.toBeNull(); + } + + expect(sink.calls.error).toHaveLength(0); + expect(sink.calls.warn).toHaveLength(0); + expect(sink.calls.info).toHaveLength(0); + // Invariance of the success path: the declared semantics hold verbatim — + // `use_count` "incremented on every successful resolution", `last_used_at` stamped. + expect(base._tables.sys_share_link[0].use_count).toBe(5); + expect(typeof base._tables.sys_share_link[0].last_used_at).toBe('string'); + expect(Number.isNaN(Date.parse(base._tables.sys_share_link[0].last_used_at))).toBe(false); + }); + + // "At the FIRST degradation" is not "on the first call": storage that starts + // refusing after a healthy run is reported at the moment it turns, once. + it('the first degradation after healthy stamps is reported, once, and later refusals stay silent', async () => { + const { engine, refusing, base } = makeRefusingEngine(REFUSAL); + refusing.on = false; + const sink = makeSink(); + const service = new ShareLinkService({ engine: engine as any, logger: sink.logger }); + const link = await mint(service); + + await service.resolveToken(link.token); + await service.resolveToken(link.token); + expect(sink.calls.error).toHaveLength(0); + expect(base._tables.sys_share_link[0].use_count).toBe(2); + + refusing.on = true; + for (let i = 0; i < 3; i++) expect(await service.resolveToken(link.token)).not.toBeNull(); + + expect(sink.calls.error).toHaveLength(1); + expect(sink.calls.warn).toHaveLength(0); + // The counters froze at the last landed value — exactly the drift the line reports. + expect(base._tables.sys_share_link[0].use_count).toBe(2); + }); + + // The sink's `error` is optional by contract (#9754: hosts inject reduced + // sinks); `warn` is the guaranteed channel. A `{ warn }`-only host must still + // hear the report — a conditional `error?.(…)` call would have emitted nothing. + it('falls back to the guaranteed warn channel when the host sink declares no error', async () => { + const { engine } = makeRefusingEngine(REFUSAL); + const warns: any[][] = []; + const service = new ShareLinkService({ + engine: engine as any, + logger: { warn: (...a: any[]) => { warns.push(a); } }, + }); + const link = await mint(service); + + for (let i = 0; i < 3; i++) expect(await service.resolveToken(link.token)).not.toBeNull(); + + expect(warns).toHaveLength(1); + expect(warns[0][0]).toContain('use_count'); + expect(warns[0][1]).toMatchObject({ link: link.id, reason: 'STORAGE_REFUSED' }); + }); + + it('a host with no logger at all is served exactly as before — the resolution never throws', async () => { + const { engine } = makeRefusingEngine(REFUSAL); + const service = new ShareLinkService({ engine: engine as any }); + const link = await mint(service); + await expect(service.resolveToken(link.token)).resolves.not.toBeNull(); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/share-link-service.ts b/packages/plugins/plugin-sharing/src/share-link-service.ts index 32a9e69888..501a02dc44 100644 --- a/packages/plugins/plugin-sharing/src/share-link-service.ts +++ b/packages/plugins/plugin-sharing/src/share-link-service.ts @@ -406,6 +406,12 @@ export class ShareLinkService implements IShareLinkService { context: ExecutionContext, ) => Promise; private readonly logger?: ShareLinkServiceOptions['logger']; + /** + * [#12981] Latched by the FIRST refused usage stamp on this instance and + * never reset; `reportUsageStampRefusal` reads it so the durability report + * is made once, not once per refused write (the rule's own words). + */ + private usageStampRefusalReported = false; constructor(opts: ShareLinkServiceOptions) { this.engine = opts.engine; @@ -698,7 +704,10 @@ export class ShareLinkService implements IShareLinkService { new Set([...(policy.redactFields ?? []), ...((row.redact_fields as string[]) ?? [])]), ); - // Stamp usage. Errors here MUST NOT block the read — log-and-continue. + // Stamp usage. A refusal here MUST NOT block the read — by this line the + // token, the record and the policy have all answered and the holder is + // owed the record — but it is a DURABILITY degradation, not telemetry to + // drop on the floor: see `reportUsageStampRefusal` (#12981). try { await this.engine.update( 'sys_share_link', @@ -709,13 +718,80 @@ export class ShareLinkService implements IShareLinkService { }, { context: SYSTEM_CTX }, ); - } catch { - // best-effort — usage telemetry is a nice-to-have + } catch (err) { + this.reportUsageStampRefusal(row, err); } return { link: row, redactFields }; } + /** + * [#12981] Storage refused the `use_count` / `last_used_at` stamp that + * `resolveToken` issues on a successful resolution. Report it as a + * durability degradation — ONCE per service instance. + * + * ## Why this is a durability site and not "usage telemetry" + * + * The catch this reporter replaced said "best-effort — usage telemetry is a + * nice-to-have". The persistence CLAIM, though, is not made by + * `resolveToken`'s response (which carries neither counter; its two public + * HTTP callers project a nine-field whitelist that excludes both). It is + * made by the declarations: `sys_share_link` declares `use_count` as + * "Incremented by resolveToken on every successful resolution" and + * `last_used_at` as "Stamped by resolveToken; used by the dashboard to + * highlight active links", both `readonly: true` — which is exactly why this + * write goes out under `SYSTEM_CTX` (`isSystem` exempts statically readonly + * fields). And the shipped `active_links` grid lists both columns. So after + * a swallowed refusal: HTTP 200 to the holder, and an admin grid asserting a + * count the system's own declaration defines — now wrong, with no signal + * anywhere. AGENTS.md → "Degradation log levels", in its own words: + * persisted state and runtime state disagree while nothing looks broken. + * ⇒ `error`, not `warn`. Neither legal alternative applies: the failure is + * handed to no caller, and a write was genuinely issued. + * + * ## Why ONCE, per instance, never reset + * + * `resolveToken` runs on EVERY public share-link request. A line per refused + * stamp is the mirror-image failure the rule names — "say it once, at the + * first degradation, not once per failed write" — a flood nobody reads, + * which is what made the founding incident's `warn` unreadable. The latch is + * per service instance (the plugin builds one) and deliberately does not + * reset on a later successful stamp: a latch that reset would print on every + * other request under flapping storage, i.e. the per-request flood again. + * Later refusals are silent BY DESIGN, and the one line says so. + * + * ## The sink, and why `error` is reachable here (#13398 class ruling) + * + * `ShareLinkServiceOptions['logger']` is the `{ info?, warn, error? }` shape + * — `error` optional, `warn` required and guaranteed (#9754 / #10556) — the + * ruling's own option-C terminal shape, and already published. What the + * ruling forbids is raising a site to `error` when that means GROWING + * `error?` onto a published sink that lacks it (its option B); this sink + * declares it, so nothing is widened. Spelled the `outbox-sweep.ts` way: a + * conditional `error?.(…)` call against a host sink without `error` emits + * nothing, so the `warn` fallback is an explicit branch. + */ + private reportUsageStampRefusal(row: ShareLink, err: unknown): void { + if (this.usageStampRefusalReported) return; + this.usageStampRefusalReported = true; + const cause = (err as { message?: unknown } | null | undefined)?.message ?? err; + const message = + '[share-link] usage stamp REFUSED — `use_count` / `last_used_at` on `sys_share_link` are NOT being ' + + 'persisted. Links keep resolving normally (the holder is still served the record), so nothing looks ' + + 'broken, but the `active_links` grid and every "how often was this link used" audit now under-count. ' + + 'Fix: resolve the storage refusal named as the cause (the `sys_share_link` table, the driver, or the ' + + 'system-context write path); stamps refused meanwhile are NOT replayed. Reported ONCE per service ' + + `instance — later refusals are silent. Cause: ${String(cause)}`; + const meta = { + link: row.id, + object: row.object_name, + record: row.record_id, + reason: (err as { code?: unknown } | null | undefined)?.code ?? 'UNKNOWN', + }; + if (this.logger?.error) this.logger.error(message, meta); + else this.logger?.warn?.(message, meta); + } + /** * [#5190 / #13608] Read the shared record at redemption time: the existence * probe, and — when the object declares an eligibility predicate — the row From 51de322d363e7290d1ca01a61a9558e7c26017cb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 03:12:32 +0000 Subject: [PATCH 2/3] chore(census): repoint the swallow-family dark control from the repaired share-link stamp to harness.ts The census self-test pinned `share-link-service.ts`'s usage stamp as its tier-1 DARK positive control, with the instruction to repoint at another member ruled OUT if a later card repaired it. Batch 9 repaired it, so the control now names `packages/verify/src/harness.ts` (`inviteForAudienceGate`), the member batch 8 judged out of the programme on the merits and annotated in place. No reading changes: 55/36, DARK 4/4 before and after the repoint. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --- scripts/measure-durability-swallow-family.mjs | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/scripts/measure-durability-swallow-family.mjs b/scripts/measure-durability-swallow-family.mjs index e2a7fa5b8b..ff1b250198 100644 --- a/scripts/measure-durability-swallow-family.mjs +++ b/scripts/measure-durability-swallow-family.mjs @@ -343,22 +343,29 @@ const POSITIVE_CONTROLS = [ tier: 'carries-error', }, { - file: 'packages/plugins/plugin-sharing/src/share-link-service.ts', + file: 'packages/verify/src/harness.ts', why: - '`resolveToken`\'s usage stamp: `try { await this.engine.update(\'sys_share_link\', ...) } ' - + 'catch { /* best-effort -- usage telemetry is a nice-to-have */ }`. Silent by the log axis, an ' - + 'awaited write in the try, and nothing bound -- the dark shape exactly.\n\n' - + 'CHOSEN FOR ITS STABILITY, which is the property a dark control needs and the previous one ' - + 'lacked. This control used to name `bootstrap-system-capabilities.ts`, whose `why` read "the ' + '`inviteForAudienceGate`\'s invitation row: `try { await engine.insert(\'sys_invitation\', ...) } ' + + 'catch { /* Best-effort -- the gate answers either way. */ }`. Silent by the log axis, an awaited ' + + 'write in the try, and nothing bound -- the dark shape exactly.\n\n' + + 'CHOSEN FOR ITS STABILITY, which is the property a dark control needs and the previous two ' + + 'lacked. This control first named `bootstrap-system-capabilities.ts`, whose `why` read "the ' + 'card\'s shape, verbatim, still standing" -- and #12981 batch 2 repaired it, which turned this ' - + 'self-test red for doing exactly what the ruling asked. ANY tier-1 DARK member of the worklist ' - + 'is a control the repair programme is designed to destroy, so repointing at another one only ' - + 'moves the breakage to the batch that repairs THAT file. This member is different: batch 1 ' - + 'judged it OUT of the programme on the merits -- the swallowed write is a `use_count` / ' - + '`last_used_at` telemetry stamp, and escalating a FUNCTIONAL degradation to `error` is the ' - + 'over-application AGENTS.md forbids -- so it is a genuine dark member with a recorded reason ' - + 'to stay one. ⛔ If a later card ever does repair it, repoint this control at another member ' - + 'ruled OUT rather than at a member merely not repaired YET.', + + 'self-test red for doing exactly what the ruling asked. It then named `share-link-service.ts`\'s ' + + 'usage stamp on the strength of batch 1\'s reading that a `use_count` / `last_used_at` stamp is ' + + 'telemetry -- and batch 8 REVERSED that reading (the persistence claim is made by the field ' + + 'declarations and the shipped `active_links` grid, not by the resolve response), so batch 9 ' + + 'repaired it and this self-test went red again. ANY tier-1 DARK member of the worklist is a ' + + 'control the repair programme is designed to destroy, so repointing at another one only moves ' + + 'the breakage to the batch that repairs THAT file. This member is different: batch 8 judged it ' + + 'OUT of the programme on the merits and wrote the determination into the file itself (the ' + + 'comment inside this very catch): nothing claims to have persisted -- the helper answers `void` ' + + 'and its only caller is the `signUp` that POSTs the sign-up on the very next line -- and the ' + + 'loss is answered one line later, LOUDLY: under the default `invite_only` posture a missing ' + + 'invitation makes that POST refuse and `signUp` throws with the audience gate\'s own status and ' + + 'body. So it is a genuine dark member with a recorded reason to stay one. ⛔ If a later card ' + + 'ever does repair it, repoint this control at another member ruled OUT rather than at a member ' + + 'merely not repaired YET.', tier: 'dark', }, ]; From 8d79c623f4e306cf9d7b079d2626102c3d9a329a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 04:03:44 +0000 Subject: [PATCH 3/3] docs(permissions): shift the system-context census anchors with share-link-service.ts (#12981 batch 9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The batch-9 repair inserted lines above the five `isSystem` read sites in `share-link-service.ts`, so the census anchors on `system-context.mdx` row 37 rotted by +6 (434→440, 488→494, 492→498, 565→571, 595→601). Re-anchored by the gate's own `--fix`; population unchanged (109 sites, 20 packages, 45 files). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --- content/docs/permissions/system-context.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index d5f735c117..302d12d9a4 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -135,7 +135,7 @@ The largest single consumer — **20 of the 109 sites**. | 34 | `revoke()` deletes directly, **before** the non-manual-source guard | Get: the evaluator can revoke its own grants. Lose: the `CONFLICT` guard that warns a rule-materialised grant will be silently re-granted on the next reconcile | `plugin-sharing/src/sharing-service.ts:1286` (guard at `:1311`) | | 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1338` | | 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1077` | -| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:434`, `:488`, `:492`, `:565`, `:595` | +| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:440`, `:494`, `:498`, `:571`, `:601` | | 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` | | 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |