Skip to content

Commit 953a81f

Browse files
claude[bot]claude
andauthored
fix(plugin-sharing): report a refused resolveToken usage stamp once as a durability degradation — #12981 batch 9 (#14383)
* 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * docs(permissions): shift the system-context census anchors with share-link-service.ts (#12981 batch 9) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3171324 commit 953a81f

5 files changed

Lines changed: 322 additions & 18 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/plugin-sharing": patch
3+
---
4+
5+
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)
6+
7+
`ShareLinkService.resolveToken` stamps `use_count` and `last_used_at` on
8+
`sys_share_link` after every successful resolution. The stamp's `catch` was
9+
empty ("usage telemetry is a nice-to-have"), so a storage refusal — a
10+
read-only database, a missing table, a broken system-context write path —
11+
left the link resolving normally while both counters silently froze.
12+
13+
Those counters are a persistence CLAIM, not telemetry: `sys_share_link`
14+
declares `use_count` as "Incremented by resolveToken on every successful
15+
resolution" and `last_used_at` as "Stamped by resolveToken; used by the
16+
dashboard to highlight active links", and the shipped `active_links` grid
17+
lists both. After a swallowed refusal an administrator read a count the
18+
system's own declaration defines, wrong, with no signal anywhere — the
19+
AGENTS.md "Degradation log levels" shape (persisted state and runtime state
20+
disagree while nothing looks broken).
21+
22+
**What changed.** The refusal is now reported through the service's existing
23+
`logger` option — the published `{ info?, warn, error? }` shape — at `error`,
24+
falling back to the guaranteed `warn` channel when the host sink declares no
25+
`error`. The line names the consequence (both counters are not being
26+
persisted; links keep resolving; the `active_links` grid under-counts), the
27+
fix (resolve the storage refusal named as the cause; refused stamps are not
28+
replayed), and the cause. It is emitted **once per service instance**, at the
29+
first refusal, never per request — `resolveToken` runs on every public
30+
share-link request, and a line per refused stamp would be the flood the rule
31+
forbids.
32+
33+
**What did NOT change**, and is pinned: the resolution itself (the holder is
34+
still served, `redactFields` is unchanged, `resolveToken` never throws for a
35+
refused stamp); the success path (`use_count` still increments and
36+
`last_used_at` is still stamped on every successful resolution); the public
37+
HTTP projection; and `ShareLinkServiceOptions` — no member is added or
38+
widened, so hosts compile exactly as before.

content/docs/permissions/system-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ The largest single consumer — **20 of the 109 sites**.
135135
| 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`) |
136136
| 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1338` |
137137
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1077` |
138-
| 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` |
138+
| 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` |
139139
| 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` |
140140
| 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` |
141141

packages/plugins/plugin-sharing/src/share-link-service.test.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,3 +607,186 @@ describe('[#13856] declared redactFields survive publicSharing opt-out', () => {
607607
expect(caught.code).toBe('SHARING_NOT_ENABLED');
608608
});
609609
});
610+
611+
// [#12981, batch 9] The `use_count` / `last_used_at` stamp at the end of
612+
// `resolveToken` used to be swallowed by an empty `catch` ("usage telemetry is
613+
// a nice-to-have"). It is a durability site: `sys_share_link` DECLARES both
614+
// counters as written by `resolveToken`, and the shipped `active_links` grid
615+
// asserts them — so a refused stamp left an admin grid asserting a number the
616+
// system's own declaration defines, wrongly, with no signal. The repair reports
617+
// the refusal through the service's existing `{ info?, warn, error? }` sink at
618+
// `error` (falling back to the guaranteed `warn`), ONCE per instance, and
619+
// leaves the resolution itself untouched.
620+
describe('[#12981] a refused usage stamp is reported ONCE as a durability degradation', () => {
621+
/** A sink that records every call, per level, so counts are exact. */
622+
function makeSink() {
623+
const calls = { error: [] as any[][], warn: [] as any[][], info: [] as any[][] };
624+
return {
625+
calls,
626+
logger: {
627+
info: (...a: any[]) => { calls.info.push(a); },
628+
warn: (...a: any[]) => { calls.warn.push(a); },
629+
error: (...a: any[]) => { calls.error.push(a); },
630+
},
631+
};
632+
}
633+
634+
/**
635+
* An engine whose `sys_share_link` UPDATE is refused with `err` for as long
636+
* as `refusing.on` is true — every other operation (find / insert / the
637+
* record probe) is the plain fake, so the ONLY thing that fails is the stamp.
638+
*/
639+
function makeRefusingEngine(err: unknown) {
640+
const base = makeFakeEngine(SCHEMAS);
641+
base._tables.ai_conversations = [{ id: 'c1', title: 'Demo' }];
642+
const refusing = { on: true };
643+
const engine = {
644+
...base,
645+
async update(object: string, idOrData: any, dataOrOptions?: any) {
646+
if (refusing.on && object === 'sys_share_link') throw err;
647+
return base.update(object, idOrData, dataOrOptions);
648+
},
649+
};
650+
return { base, engine, refusing };
651+
}
652+
653+
async function mint(service: ShareLinkService) {
654+
return service.createLink(
655+
{ object: 'ai_conversations', recordId: 'c1', audience: 'link_only', permission: 'view' },
656+
{ userId: 'u1' },
657+
);
658+
}
659+
660+
const REFUSAL = Object.assign(new Error('SQLITE_READONLY: attempt to write a readonly database'), {
661+
code: 'STORAGE_REFUSED',
662+
});
663+
664+
it('positive — the link still resolves, and the refusal is reported at error naming both counters', async () => {
665+
const { engine, base } = makeRefusingEngine(REFUSAL);
666+
const sink = makeSink();
667+
const service = new ShareLinkService({ engine: engine as any, logger: sink.logger });
668+
const link = await mint(service);
669+
670+
const resolved = await service.resolveToken(link.token);
671+
672+
// The resolution is UNCHANGED by the refusal: the holder is served.
673+
expect(resolved).not.toBeNull();
674+
expect(resolved!.link.id).toBe(link.id);
675+
expect(resolved!.redactFields).toEqual(['metadata']);
676+
// ...and the counters genuinely did not move — the thing being reported.
677+
expect(base._tables.sys_share_link[0].use_count).toBe(0);
678+
expect(base._tables.sys_share_link[0].last_used_at).toBeNull();
679+
680+
// The report: exactly one, at `error`, not degraded to `warn` while
681+
// `error` is available. Consequence and fix in the one line, plus the
682+
// cause, per AGENTS.md → "Degradation log levels".
683+
expect(sink.calls.error).toHaveLength(1);
684+
expect(sink.calls.warn).toHaveLength(0);
685+
expect(sink.calls.info).toHaveLength(0);
686+
const [message, meta] = sink.calls.error[0];
687+
expect(message).toContain('use_count');
688+
expect(message).toContain('last_used_at');
689+
expect(message).toContain('sys_share_link');
690+
expect(message).toContain('active_links');
691+
expect(message).toContain('Fix:');
692+
expect(message).toContain('SQLITE_READONLY: attempt to write a readonly database');
693+
expect(meta).toMatchObject({
694+
link: link.id,
695+
object: 'ai_conversations',
696+
record: 'c1',
697+
reason: 'STORAGE_REFUSED',
698+
});
699+
});
700+
701+
// ⭐ The "say it ONCE" pin. `resolveToken` runs on every public request, so a
702+
// line per refused stamp is the flood the rule forbids. N = 5 ≥ 3.
703+
it('say it ONCE — five consecutive refused stamps produce exactly one report', async () => {
704+
const { engine } = makeRefusingEngine(REFUSAL);
705+
const sink = makeSink();
706+
const service = new ShareLinkService({ engine: engine as any, logger: sink.logger });
707+
const link = await mint(service);
708+
709+
for (let i = 0; i < 5; i++) {
710+
// Every resolution still serves — the degradation never leaks to the holder.
711+
expect(await service.resolveToken(link.token), `resolution #${i + 1}`).not.toBeNull();
712+
}
713+
714+
expect(sink.calls.error).toHaveLength(1);
715+
expect(sink.calls.warn).toHaveLength(0);
716+
expect(sink.calls.error[0][0]).toContain('Reported ONCE');
717+
});
718+
719+
// Reverse control. Without it, "once" and "never" are indistinguishable: a
720+
// reporter that never fires also passes the pin above only through the
721+
// positive test, so the control pins the OTHER direction — a stamp that
722+
// lands produces nothing at any level.
723+
it('reverse control — five stamps that LAND produce zero output at every level', async () => {
724+
const { engine, refusing, base } = makeRefusingEngine(REFUSAL);
725+
refusing.on = false;
726+
const sink = makeSink();
727+
const service = new ShareLinkService({ engine: engine as any, logger: sink.logger });
728+
const link = await mint(service);
729+
730+
for (let i = 0; i < 5; i++) {
731+
expect(await service.resolveToken(link.token)).not.toBeNull();
732+
}
733+
734+
expect(sink.calls.error).toHaveLength(0);
735+
expect(sink.calls.warn).toHaveLength(0);
736+
expect(sink.calls.info).toHaveLength(0);
737+
// Invariance of the success path: the declared semantics hold verbatim —
738+
// `use_count` "incremented on every successful resolution", `last_used_at` stamped.
739+
expect(base._tables.sys_share_link[0].use_count).toBe(5);
740+
expect(typeof base._tables.sys_share_link[0].last_used_at).toBe('string');
741+
expect(Number.isNaN(Date.parse(base._tables.sys_share_link[0].last_used_at))).toBe(false);
742+
});
743+
744+
// "At the FIRST degradation" is not "on the first call": storage that starts
745+
// refusing after a healthy run is reported at the moment it turns, once.
746+
it('the first degradation after healthy stamps is reported, once, and later refusals stay silent', async () => {
747+
const { engine, refusing, base } = makeRefusingEngine(REFUSAL);
748+
refusing.on = false;
749+
const sink = makeSink();
750+
const service = new ShareLinkService({ engine: engine as any, logger: sink.logger });
751+
const link = await mint(service);
752+
753+
await service.resolveToken(link.token);
754+
await service.resolveToken(link.token);
755+
expect(sink.calls.error).toHaveLength(0);
756+
expect(base._tables.sys_share_link[0].use_count).toBe(2);
757+
758+
refusing.on = true;
759+
for (let i = 0; i < 3; i++) expect(await service.resolveToken(link.token)).not.toBeNull();
760+
761+
expect(sink.calls.error).toHaveLength(1);
762+
expect(sink.calls.warn).toHaveLength(0);
763+
// The counters froze at the last landed value — exactly the drift the line reports.
764+
expect(base._tables.sys_share_link[0].use_count).toBe(2);
765+
});
766+
767+
// The sink's `error` is optional by contract (#9754: hosts inject reduced
768+
// sinks); `warn` is the guaranteed channel. A `{ warn }`-only host must still
769+
// hear the report — a conditional `error?.(…)` call would have emitted nothing.
770+
it('falls back to the guaranteed warn channel when the host sink declares no error', async () => {
771+
const { engine } = makeRefusingEngine(REFUSAL);
772+
const warns: any[][] = [];
773+
const service = new ShareLinkService({
774+
engine: engine as any,
775+
logger: { warn: (...a: any[]) => { warns.push(a); } },
776+
});
777+
const link = await mint(service);
778+
779+
for (let i = 0; i < 3; i++) expect(await service.resolveToken(link.token)).not.toBeNull();
780+
781+
expect(warns).toHaveLength(1);
782+
expect(warns[0][0]).toContain('use_count');
783+
expect(warns[0][1]).toMatchObject({ link: link.id, reason: 'STORAGE_REFUSED' });
784+
});
785+
786+
it('a host with no logger at all is served exactly as before — the resolution never throws', async () => {
787+
const { engine } = makeRefusingEngine(REFUSAL);
788+
const service = new ShareLinkService({ engine: engine as any });
789+
const link = await mint(service);
790+
await expect(service.resolveToken(link.token)).resolves.not.toBeNull();
791+
});
792+
});

packages/plugins/plugin-sharing/src/share-link-service.ts

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,12 @@ export class ShareLinkService implements IShareLinkService {
406406
context: ExecutionContext,
407407
) => Promise<boolean>;
408408
private readonly logger?: ShareLinkServiceOptions['logger'];
409+
/**
410+
* [#12981] Latched by the FIRST refused usage stamp on this instance and
411+
* never reset; `reportUsageStampRefusal` reads it so the durability report
412+
* is made once, not once per refused write (the rule's own words).
413+
*/
414+
private usageStampRefusalReported = false;
409415

410416
constructor(opts: ShareLinkServiceOptions) {
411417
this.engine = opts.engine;
@@ -698,7 +704,10 @@ export class ShareLinkService implements IShareLinkService {
698704
new Set<string>([...(policy.redactFields ?? []), ...((row.redact_fields as string[]) ?? [])]),
699705
);
700706

701-
// Stamp usage. Errors here MUST NOT block the read — log-and-continue.
707+
// Stamp usage. A refusal here MUST NOT block the read — by this line the
708+
// token, the record and the policy have all answered and the holder is
709+
// owed the record — but it is a DURABILITY degradation, not telemetry to
710+
// drop on the floor: see `reportUsageStampRefusal` (#12981).
702711
try {
703712
await this.engine.update(
704713
'sys_share_link',
@@ -709,13 +718,80 @@ export class ShareLinkService implements IShareLinkService {
709718
},
710719
{ context: SYSTEM_CTX },
711720
);
712-
} catch {
713-
// best-effort — usage telemetry is a nice-to-have
721+
} catch (err) {
722+
this.reportUsageStampRefusal(row, err);
714723
}
715724

716725
return { link: row, redactFields };
717726
}
718727

728+
/**
729+
* [#12981] Storage refused the `use_count` / `last_used_at` stamp that
730+
* `resolveToken` issues on a successful resolution. Report it as a
731+
* durability degradation — ONCE per service instance.
732+
*
733+
* ## Why this is a durability site and not "usage telemetry"
734+
*
735+
* The catch this reporter replaced said "best-effort — usage telemetry is a
736+
* nice-to-have". The persistence CLAIM, though, is not made by
737+
* `resolveToken`'s response (which carries neither counter; its two public
738+
* HTTP callers project a nine-field whitelist that excludes both). It is
739+
* made by the declarations: `sys_share_link` declares `use_count` as
740+
* "Incremented by resolveToken on every successful resolution" and
741+
* `last_used_at` as "Stamped by resolveToken; used by the dashboard to
742+
* highlight active links", both `readonly: true` — which is exactly why this
743+
* write goes out under `SYSTEM_CTX` (`isSystem` exempts statically readonly
744+
* fields). And the shipped `active_links` grid lists both columns. So after
745+
* a swallowed refusal: HTTP 200 to the holder, and an admin grid asserting a
746+
* count the system's own declaration defines — now wrong, with no signal
747+
* anywhere. AGENTS.md → "Degradation log levels", in its own words:
748+
* persisted state and runtime state disagree while nothing looks broken.
749+
* ⇒ `error`, not `warn`. Neither legal alternative applies: the failure is
750+
* handed to no caller, and a write was genuinely issued.
751+
*
752+
* ## Why ONCE, per instance, never reset
753+
*
754+
* `resolveToken` runs on EVERY public share-link request. A line per refused
755+
* stamp is the mirror-image failure the rule names — "say it once, at the
756+
* first degradation, not once per failed write" — a flood nobody reads,
757+
* which is what made the founding incident's `warn` unreadable. The latch is
758+
* per service instance (the plugin builds one) and deliberately does not
759+
* reset on a later successful stamp: a latch that reset would print on every
760+
* other request under flapping storage, i.e. the per-request flood again.
761+
* Later refusals are silent BY DESIGN, and the one line says so.
762+
*
763+
* ## The sink, and why `error` is reachable here (#13398 class ruling)
764+
*
765+
* `ShareLinkServiceOptions['logger']` is the `{ info?, warn, error? }` shape
766+
* — `error` optional, `warn` required and guaranteed (#9754 / #10556) — the
767+
* ruling's own option-C terminal shape, and already published. What the
768+
* ruling forbids is raising a site to `error` when that means GROWING
769+
* `error?` onto a published sink that lacks it (its option B); this sink
770+
* declares it, so nothing is widened. Spelled the `outbox-sweep.ts` way: a
771+
* conditional `error?.(…)` call against a host sink without `error` emits
772+
* nothing, so the `warn` fallback is an explicit branch.
773+
*/
774+
private reportUsageStampRefusal(row: ShareLink, err: unknown): void {
775+
if (this.usageStampRefusalReported) return;
776+
this.usageStampRefusalReported = true;
777+
const cause = (err as { message?: unknown } | null | undefined)?.message ?? err;
778+
const message =
779+
'[share-link] usage stamp REFUSED — `use_count` / `last_used_at` on `sys_share_link` are NOT being '
780+
+ 'persisted. Links keep resolving normally (the holder is still served the record), so nothing looks '
781+
+ 'broken, but the `active_links` grid and every "how often was this link used" audit now under-count. '
782+
+ 'Fix: resolve the storage refusal named as the cause (the `sys_share_link` table, the driver, or the '
783+
+ 'system-context write path); stamps refused meanwhile are NOT replayed. Reported ONCE per service '
784+
+ `instance — later refusals are silent. Cause: ${String(cause)}`;
785+
const meta = {
786+
link: row.id,
787+
object: row.object_name,
788+
record: row.record_id,
789+
reason: (err as { code?: unknown } | null | undefined)?.code ?? 'UNKNOWN',
790+
};
791+
if (this.logger?.error) this.logger.error(message, meta);
792+
else this.logger?.warn?.(message, meta);
793+
}
794+
719795
/**
720796
* [#5190 / #13608] Read the shared record at redemption time: the existence
721797
* probe, and — when the object declares an eligibility predicate — the row

0 commit comments

Comments
 (0)