@lenso/audit 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHECKS.md ADDED
@@ -0,0 +1,84 @@
1
+ # Audit validation
2
+
3
+ ## Passed in this checkout
4
+
5
+ - Bun 1.4.2 with repository-pinned dependency versions.
6
+ - Required framework dependency builds, then `bun run --cwd packages/audit build`.
7
+ - `bun run --cwd packages/audit typecheck`.
8
+ - `bun run --cwd examples/notes typecheck` and `bun run --cwd examples/notes build`.
9
+ - `LENSO_REQUIRE_POSTGRES=1 bun test packages/audit/test`: **15 passed, 0 failed**.
10
+ - Real Bun SQLite: reopen persistence, duplicates/conflicts, exact tenant/scope
11
+ isolation, filters and tied-time keyset pagination.
12
+ - Real disposable PostgreSQL: independent connections, concurrent duplicate
13
+ inserts, JSON roundtrip, scope/filter/page checks; `fsync=on` and
14
+ `synchronous_commit=on`; a strict intent is visible from another connection
15
+ before continuing, and a duplicate intent never returns another receipt.
16
+ - Actual local Miniflare/workerd D1 binding: explicit migration, insert/returning,
17
+ duplicate/conflict lookup, tenant/scope isolation, filters and pagination.
18
+ - Auth: genuine minted actors, copied/foreign-audience/foreign-runtime actors,
19
+ revoked sessions, scope denial and Auth-valid opaque subject/issuer IDs.
20
+ - Whitelisting/length limits, client identity rejection, append-only correction,
21
+ safe failure reporting, strict admission, post-effect unknown, system subjects.
22
+ - Exact plugin dependencies, invalid-config preflight, owned stop/rollback
23
+ cleanup and borrowed database survival.
24
+ - Existing Notes removal through real Manage/Engine/Auth/SQLite, including
25
+ cross-owner denial; Audit query-only companion, scope denial, no counts,
26
+ secret/body omission and 129-character target-ID filtering.
27
+ - Tasks reconciliation registration: locator-only payload, per-attempt trusted
28
+ principal, linked stable outcome and denied unauthorized job attempts.
29
+ - Standalone root bundled without optional framework/provider imports.
30
+ - `bun test examples/notes/test/notes.test.ts examples/notes/test/operations.test.ts examples/notes/test/manage.test.ts`:
31
+ **7 passed, 0 failed**, including real CLI inspect/call subprocesses.
32
+ - `bun packages/cli/src/bin.ts inspect notes-operations remove --root examples/notes --json`:
33
+ existing strict business input and unchanged opt-out assembly.
34
+ - Focused `oxlint --deny-warnings`: **0 warnings, 0 errors**.
35
+ - Focused `oxfmt --check` and `git diff --check`: passed.
36
+
37
+ ## Clean-install integration
38
+
39
+ After explicit authorization, the single root `bun.lock` was regenerated with
40
+ `bun install --lockfile-only --ignore-scripts`. Its changes are the Audit
41
+ workspace, Notes' Audit dev dependency, and existing Auth/Manage manifest peer
42
+ ranges that were already `^0.2.0` but stale in the previous lock. No external
43
+ dependency version or public Auth/Tasks/Manage source was changed.
44
+
45
+ A disposable non-Git source tree was copied from the current workspace without
46
+ `node_modules`, `dist` or Turbo cache. It passed:
47
+
48
+ ```sh
49
+ bun install --frozen-lockfile
50
+ bun run build --filter=@lenso/audit... --filter=@lenso/example-notes... --concurrency=1 --cache=local:rw
51
+ bun run --cwd packages/audit typecheck
52
+ bun run --cwd examples/notes typecheck
53
+ LENSO_REQUIRE_POSTGRES=1 bun test packages/audit/test
54
+ bun test examples/notes/test/notes.test.ts examples/notes/test/operations.test.ts examples/notes/test/manage.test.ts
55
+ ```
56
+
57
+ All **14 selected package builds** ran successfully on the first build, with
58
+ no cache hits and remote caching disabled. Audit again passed **15 tests**,
59
+ including actual PostgreSQL and local workerd D1; Notes again passed **7 tests**.
60
+ Focused lint and formatting checks also passed there. The frozen installation
61
+ left the copied lock byte-identical to the updated workspace lock.
62
+
63
+ ## Not claimed or verified
64
+
65
+ - No cloud D1 deployment, replication/failover, production PostgreSQL/SQLite
66
+ durability, power-loss recovery, or broad platform compatibility.
67
+ - No business/Audit atomic transaction or outbox integration. Notes uses
68
+ best-effort; strict is explicit persisted-intent admission, not rollback or
69
+ exactly-once external execution.
70
+ - No real notifications, payments, production mutations, credential provisioning,
71
+ publishing, pushing or merging.
72
+ - Tasks registration/handler behavior was checked, **not a new durable Tasks
73
+ provider/end-to-end queue run**. Use the existing provider and its tests;
74
+ queue enqueue is an independent write and cannot replace a missing intent.
75
+ - No OTel SDK/exporter end-to-end run; diagnostics reuse the existing API and
76
+ supplied logger, without acquiring or closing an SDK.
77
+ - No full-repository test/release suite, browser tests or compliance claim.
78
+
79
+ ## Remaining consumer setup
80
+
81
+ Apply the selected Audit SQL baseline through the consumer's existing migration
82
+ history, supply the actual scope policy and diagnostic sink, and explicitly opt
83
+ in to the Notes Audit dependency or query companion. No default management
84
+ entry, listener, automatic migration or retry was enabled.
package/README.md CHANGED
@@ -1,3 +1,241 @@
1
- # Temporary Holding Version
1
+ # Audit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `@lenso/audit` records who did what to which resource, in which scope, when, and
4
+ with what outcome. Log/OTel remains the runtime diagnostic channel. Audit is not
5
+ an authorization engine, compliance certification, tamper-proof ledger, or a
6
+ guarantee of lossless delivery.
7
+
8
+ ## Entries and ownership
9
+
10
+ | Entry | Purpose | Additional packages |
11
+ | -------------- | -------------------------------------------------------------- | ------------------------- |
12
+ | `@lenso/audit` | Ordinary async service and contracts | None |
13
+ | `/auth` | Revalidate an actual Auth actor and exact scope policy | Auth (type-only) |
14
+ | `/plugin` | Thin exact-instance registration and page-size Config contract | Core, Zod |
15
+ | `/sqlite` | Drizzle Bun SQLite or D1 repository and schema | Drizzle |
16
+ | `/postgres` | Drizzle Bun SQL PostgreSQL repository and schema | Drizzle |
17
+ | `/diagnostics` | Bounded OTel failure counter and supplied logger | OTel API |
18
+ | `/manage` | Explicit query-only companion, no automatic entry | Core, Engine, Manage, Zod |
19
+ | `/tasks` | Existing Tasks reconciliation registration | Tasks, Zod |
20
+
21
+ Root imports do not load optional integrations. Install only the peers for entries
22
+ you use. Repositories borrow databases and never close them. Use the existing DB
23
+ resource plugins to own connections; they register cleanup during setup. Startup
24
+ does not run migrations. Drain application calls before stopping their DB owner.
25
+
26
+ ## Ordinary service
27
+
28
+ ```ts
29
+ import { createAuditService } from "@lenso/audit";
30
+ import { createAuthAuditAuthority } from "@lenso/audit/auth";
31
+ import { createSqliteAuditRepository } from "@lenso/audit/sqlite";
32
+ import { createAuditReporter } from "@lenso/audit/diagnostics";
33
+
34
+ // db, access and logger are existing, explicitly supplied application instances.
35
+ const audit = createAuditService({
36
+ repository: createSqliteAuditRepository(db),
37
+ authority: createAuthAuditAuthority(
38
+ access,
39
+ ({ principal, scope }) =>
40
+ principal.kind === "user" &&
41
+ scope.tenantId === null &&
42
+ scope.scopeId === `owner:${principal.subjectId}`,
43
+ ),
44
+ summaryPolicy: { "notes.remove": { removed: { type: "boolean" } } },
45
+ report: createAuditReporter({ logger }),
46
+ });
47
+
48
+ // actor comes from access.required(trustedRequestEvidence), never business JSON.
49
+ const status = await audit.appendBestEffort(
50
+ {
51
+ id: crypto.randomUUID(),
52
+ occurredAt: Date.now(),
53
+ scope: { tenantId: null, scopeId: `owner:${actor.subjectId}` },
54
+ action: "notes.remove",
55
+ target: { type: "note", id: noteId },
56
+ result: "success",
57
+ reasonCode: "removed",
58
+ summary: { removed: true },
59
+ },
60
+ actor,
61
+ );
62
+ ```
63
+
64
+ `AuditAuthority.resolve` is the trusted server boundary: it must authenticate,
65
+ revalidate and authorize the requested `append` or `query` scope, returning only
66
+ an identity snapshot. The Auth companion calls the existing `Access.enforce`,
67
+ so copied actors, another audience/runtime and revoked sessions fail. Custom
68
+ authorities can use a server-owned system principal and explicitly return
69
+ `{kind:"system", systemId:"maintenance"}`; missing identity is an error, not a
70
+ fabricated user. Do not implement an authority by trusting a JSON actor or
71
+ unconditionally accepting an arbitrary caller object.
72
+
73
+ Events require a stable lowercase UUID, server-supplied occurrence time
74
+ (integer milliseconds), action, target, result and reason code. Recording time
75
+ comes from the service clock. They optionally carry correlation and a relation.
76
+ Subject, target, scope and correlation IDs must be opaque identifiers, not
77
+ credentials, signed URLs, email addresses or request content. Business identifiers are
78
+ bounded ASCII (`A-Z`, `a-z`, digits, `.`, `_`, `:`, `/`, `-`), with no URL scheme.
79
+ Targets permit 256 characters; reason codes 64. Trusted Auth identity snapshots
80
+ retain Auth's own bounded realm/subject grammar (256/512 characters), including
81
+ opaque subject IDs such as `auth0|alice` and issuer URI realms. Map such subjects
82
+ to an application-owned scope identifier if they are not scope-token compatible;
83
+ do not rewrite or hash credentials to manufacture an audit identity.
84
+
85
+ Summaries default to empty. Each action explicitly whitelists at most 16 fields
86
+ of boolean, bounded integer or fixed enum values (up to 32 literals of 64
87
+ characters). No free-form text, nested input, full body, credential hash/digest,
88
+ or arbitrary error text is accepted. Credential/body/PII-shaped field names are
89
+ rejected even when configured. Applications must still choose safe enum literals
90
+ and identifiers: validation cannot recognize a secret disguised as an opaque ID.
91
+ Unknown fields, missing tenant declaration, malformed actors and oversized data
92
+ fail explicitly rather than silently disappearing.
93
+
94
+ ## Queries, corrections and duplicates
95
+
96
+ Every `get({scope,id}, principal)` and `query({scope,...}, principal)` requires
97
+ an explicit exact scope, including for administrators. Public resources use
98
+ `tenantId: null`; tenant-bound resources require their actual tenant. Neither
99
+ missing tenant nor missing scope means all resources. The authority must enforce
100
+ actual membership/permission, not a client-provided tenant assertion.
101
+
102
+ Query filters are action, exact target, result, correlation and inclusive
103
+ recording-time bounds. Pages use descending `(recordedAt,id)` keyset cursors.
104
+ Default limit is 50, configurable maximum defaults to 100 (hard ceiling 500).
105
+ No global count is returned. A cursor is only a position within the requested
106
+ authorized query, not a permission token or snapshot; concurrent appends or clock
107
+ changes can change later pages. Do not use pagination as a complete frozen export.
108
+
109
+ The repository key is `(tenant namespace, scopeId, id)`, including a distinct
110
+ namespace for null tenants. Repeating identical immutable content returns
111
+ `duplicate` and the original recording time; changed content at that key fails
112
+ `duplicate-conflict`. There is no cross-scope ID-existence oracle.
113
+
114
+ The service and repositories have no update/delete API. Append a new event with
115
+ `relation: {kind:"correction", eventId:originalId}` to correct an existing event
116
+ in the same scope. Results use `kind:"outcome"` to link to an intent for the same
117
+ action and target. This does not prevent DB administrators, other SQL writers,
118
+ backups or retention workflows from changing/removing underlying data.
119
+
120
+ ## Strict versus best-effort
121
+
122
+ - `append` propagates safe persistence errors; a failed acknowledgement may
123
+ mean the row was committed. Retry only the **same event ID and content**.
124
+ - `appendBestEffort` requires a diagnostic reporter. A storage failure produces
125
+ `status:"unconfirmed"` and a safe failure metric/log; it does not assert the
126
+ row is absent. Authorization, malformed data and duplicate conflicts still
127
+ fail. The supplied reporter must have a working sink; reporter failures are
128
+ not swallowed. `/diagnostics` requires a logger and emits only fixed
129
+ mode/stage/code labels, never actor, tenant, target, summary or driver errors.
130
+ OTel exporters/SDK ownership stay with the existing application.
131
+ - `prepare` is the strict pre-effect gate. It is disabled unless the repository
132
+ owner explicitly attests `durableIntents:true`. A newly acknowledged intent
133
+ returns `ready` with a service-issued receipt. Duplicate intent returns
134
+ `already-recorded`, **never permission to repeat the business effect**.
135
+
136
+ ```ts
137
+ const prepared = await audit.prepare(intentEvent, actor); // result:"intent"
138
+ if (prepared.status !== "ready") {
139
+ // Consult the stored outcome/reconcile. Do not rerun the effect.
140
+ return { state: "pending-reconciliation", intentId: prepared.intentId };
141
+ }
142
+ // Business authorization is still required at the real effect boundary.
143
+ const result = await alreadyAuthorizedEffect();
144
+ await audit.complete(prepared.receipt, {
145
+ id: stableOutcomeId,
146
+ occurredAt: Date.now(),
147
+ result: "success",
148
+ reasonCode: "completed",
149
+ });
150
+ ```
151
+
152
+ The application owns effect classification. A rejected/timeout external call
153
+ may already have taken effect: append `unknown`, not an invented failure/rollback.
154
+ If `complete` cannot confirm the result, it throws `AuditOutcomeUnknownError`
155
+ with the persisted intent ID. The effect is not rolled back, automatically retried
156
+ or hidden by a successful response. Outcome recording uses the receipt's trusted
157
+ pre-effect identity snapshot, including if a session is revoked after the effect.
158
+ Receipts are instance-local, not serializable authorization grants.
159
+
160
+ `durableIntents` is a deployment assertion, not a capability detected by
161
+ Drizzle. Do not enable it for memory-only SQLite, uncommitted transaction handles,
162
+ asynchronously replicated acknowledgement paths, or unverified durability
163
+ settings. PostgreSQL requires an acknowledged commit with appropriate deployment
164
+ durability; SQLite requires a persistent database and deliberate sync settings;
165
+ D1 requires the actual binding's committed write semantics. A timed-out write
166
+ does not authorize continuing. No adapter combines independent business/Audit
167
+ writes into a transaction. PostgreSQL transactions and D1 batch atomicity apply
168
+ only when the application really submits both operations together through that
169
+ existing mechanism; this package does not provide that integration.
170
+
171
+ For durable supplementary work, `/tasks` defines a reconciliation task carrying
172
+ only `{scope,intentId}`. Its application-owned principal is reauthorized; the
173
+ resolver reads the real effect, then appends a linked outcome with a stable ID
174
+ and occurrence time. It must not repeat the effect. Install/enqueue/drain it
175
+ through existing Tasks. Enqueue after an effect is another independent write:
176
+ if enqueue fails or the process crashes first, scan persisted intents using an
177
+ authorized application workflow. There is no automatic outbox or exactly-once
178
+ claim, and no durable actor/credential in the task payload.
179
+
180
+ ## Optional Lenso and Manage wiring
181
+
182
+ `createAuditPlugin({id,repository,authority,diagnostics?,summaryPolicy?,config?})`
183
+ requires the **exact supplied plugin objects**. Config validates only
184
+ `maxPageSize`; trusted policy/providers remain code, not string DI or a global
185
+ Context. It creates no client, listener, SDK or queue worker.
186
+
187
+ `createAuditManage({id,audit})` returns a query sidecar, its one Operation and
188
+ Manage declaration. Creating it opens nothing. Install the sidecar and explicitly
189
+ select its operation for the chosen entry. Supply per-call trusted binding
190
+ `context:{principal:actualActor}` plus current-identity `canList`, then let the
191
+ service check scope. Default CLI/MCP/agent lists contain no Audit operations.
192
+ No append, complete event input, credentials or full request body is handed to
193
+ an agent. Query results still contain identities and resource IDs; select this
194
+ operation only for callers authorized to read that audit data.
195
+
196
+ The existing Notes factory accepts optional `audit` in
197
+ `createNotesOperations({notes,authentication,audit})`. Only its real `remove`
198
+ management operation records a best-effort event; Notes still performs Auth and
199
+ owner checks. Auth denials retain a known actor with a fixed reason code;
200
+ unclassified business-write errors are recorded as `unknown`, never a claimed
201
+ rollback. Assembly opts in to `notes.remove`'s `removed:boolean` summary and
202
+ owner scope as above. Omitting Audit preserves the existing graph. The focused
203
+ consumer test composes the existing Notes/Auth/DB/Manage services, not another
204
+ demo or authorization framework.
205
+
206
+ ## Migrations and provider evidence
207
+
208
+ Apply `migrations/pg/0000_audit.sql` or `migrations/sqlite/0000_audit.sql` through
209
+ the application's explicit migration history before starting the consumer.
210
+ Exported `auditPostgresSchema`/`auditSqliteSchema` match those tables and index.
211
+ SQLite SQL is also intended for D1's normal migration runner; do not mix runner
212
+ histories. The repository uses explicit composite-conflict `DO NOTHING`,
213
+ `RETURNING`, and a fresh exact-scope duplicate read. It uses no update,
214
+ interactive D1 transaction, invented conditional-write abstraction or batch
215
+ rollback promise.
216
+
217
+ Official references checked against the pinned Drizzle 0.45.3 APIs:
218
+ [Drizzle insert](https://orm.drizzle.team/docs/insert),
219
+ [Bun SQLite](https://bun.sh/docs/api/sqlite),
220
+ [Bun SQL](https://bun.sh/docs/api/sql),
221
+ [PostgreSQL INSERT](https://www.postgresql.org/docs/current/sql-insert.html),
222
+ [SQLite RETURNING](https://www.sqlite.org/lang_returning.html),
223
+ [D1 database API](https://developers.cloudflare.com/d1/worker-api/d1-database/).
224
+ D1 `batch` is sequential and rollback-on-statement-failure; it does not cover
225
+ external effects. Local workerd tests do not verify cloud replication or
226
+ production crash durability.
227
+
228
+ Focused checks:
229
+
230
+ ```sh
231
+ bun run --cwd packages/audit build
232
+ bun run --cwd packages/audit typecheck
233
+ LENSO_REQUIRE_POSTGRES=1 bun test packages/audit/test
234
+ ```
235
+
236
+ Build Core/Auth/DB/Engine/Manage/Tasks and other consumer dependencies first,
237
+ because workspace public exports resolve to `dist`. PostgreSQL tests create
238
+ their own disposable loopback cluster, never use an inherited database URL.
239
+ D1 tests use owned local Miniflare/workerd bindings. Fault-injection tests verify
240
+ failure protocol only, not a real provider's durability. See `CHECKS.md` for
241
+ actual results and unverified boundaries.
package/dist/auth.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ import type { Access, Actor } from "@lenso/auth";
2
+ import type { AuditAuthority, AuditScope } from "./contracts";
3
+ export declare function createAuthAuditAuthority<R extends string, E, S extends string, A extends string>(access: Access<R, E, S, A>, policy: (input: {
4
+ principal: Actor<R, S, A>;
5
+ scope: AuditScope;
6
+ operation: "append" | "query";
7
+ }) => boolean | Promise<boolean>): AuditAuthority<Actor<R, S, A> | null>;
package/dist/auth.js ADDED
@@ -0,0 +1,16 @@
1
+ // src/auth.ts
2
+ function createAuthAuditAuthority(access, policy) {
3
+ return {
4
+ async resolve(actor, scope, operation) {
5
+ const principal = await access.enforce(actor, scope, ({ principal: verified, resource }) => policy({ principal: verified, scope: resource, operation }));
6
+ return {
7
+ kind: principal.kind,
8
+ realmId: principal.realmId,
9
+ subjectId: principal.subjectId
10
+ };
11
+ }
12
+ };
13
+ }
14
+ export {
15
+ createAuthAuditAuthority
16
+ };
@@ -0,0 +1,102 @@
1
+ export interface AuditScope {
2
+ readonly tenantId: string | null;
3
+ readonly scopeId: string;
4
+ }
5
+ export type AuditActor = {
6
+ readonly kind: "user" | "guest" | "service";
7
+ readonly realmId: string;
8
+ readonly subjectId: string;
9
+ } | {
10
+ readonly kind: "system";
11
+ readonly systemId: string;
12
+ };
13
+ export type AuditResult = "intent" | "success" | "failure" | "denied" | "unknown";
14
+ export type SummaryValue = boolean | number | string;
15
+ export type SummaryRule = {
16
+ readonly type: "boolean";
17
+ } | {
18
+ readonly type: "integer";
19
+ readonly min: number;
20
+ readonly max: number;
21
+ } | {
22
+ readonly type: "enum";
23
+ readonly values: readonly string[];
24
+ };
25
+ export type SummaryPolicy = Readonly<Record<string, Readonly<Record<string, SummaryRule>>>>;
26
+ export interface AuditInput {
27
+ readonly id: string;
28
+ readonly occurredAt: number;
29
+ readonly scope: AuditScope;
30
+ readonly action: string;
31
+ readonly target: {
32
+ readonly type: string;
33
+ readonly id: string;
34
+ };
35
+ readonly result: AuditResult;
36
+ readonly reasonCode: string;
37
+ readonly correlationId?: string;
38
+ readonly summary?: Readonly<Record<string, SummaryValue>>;
39
+ readonly relation?: {
40
+ readonly kind: "correction" | "outcome";
41
+ readonly eventId: string;
42
+ };
43
+ }
44
+ export interface AuditEvent extends AuditInput {
45
+ readonly recordedAt: number;
46
+ readonly actor: AuditActor;
47
+ readonly summary: Readonly<Record<string, SummaryValue>>;
48
+ }
49
+ export interface AuditCursor {
50
+ readonly recordedAt: number;
51
+ readonly id: string;
52
+ }
53
+ export interface AuditQuery {
54
+ readonly scope: AuditScope;
55
+ readonly limit?: number;
56
+ readonly cursor?: AuditCursor;
57
+ readonly action?: string;
58
+ readonly target?: {
59
+ readonly type: string;
60
+ readonly id: string;
61
+ };
62
+ readonly result?: AuditResult;
63
+ readonly correlationId?: string;
64
+ readonly recordedFrom?: number;
65
+ readonly recordedTo?: number;
66
+ }
67
+ export interface AuditPage {
68
+ readonly events: readonly AuditEvent[];
69
+ readonly nextCursor: AuditCursor | null;
70
+ }
71
+ /** Trusted repository boundary. No mutation or global lookup is exposed. */
72
+ export interface AuditRepository {
73
+ readonly durableIntents: boolean;
74
+ insert(event: AuditEvent): Promise<"inserted" | "duplicate" | "conflict">;
75
+ get(scope: AuditScope, id: string): Promise<AuditEvent | null>;
76
+ list(query: AuditQuery & {
77
+ readonly limit: number;
78
+ }): Promise<readonly AuditEvent[]>;
79
+ }
80
+ export interface AuditAuthority<P> {
81
+ /** Authenticate/revalidate and authorize the exact scope, then return only an identity snapshot. */
82
+ resolve(principal: P, scope: AuditScope, operation: "append" | "query"): Promise<AuditActor>;
83
+ }
84
+ export interface AuditDiagnostic {
85
+ readonly mode: "strict" | "best-effort";
86
+ readonly stage: "intent" | "append" | "outcome";
87
+ readonly code: "storage-failed";
88
+ }
89
+ export type AuditReporter = (diagnostic: AuditDiagnostic) => void | Promise<void>;
90
+ export declare class AuditError extends Error {
91
+ readonly code: "invalid-input" | "unauthorized" | "duplicate-conflict" | "relation-missing" | "storage-failed" | "strict-unavailable" | "invalid-receipt" | "diagnostics-failed" | "diagnostics-required";
92
+ constructor(code: "invalid-input" | "unauthorized" | "duplicate-conflict" | "relation-missing" | "storage-failed" | "strict-unavailable" | "invalid-receipt" | "diagnostics-failed" | "diagnostics-required");
93
+ }
94
+ export declare class AuditOutcomeUnknownError extends Error {
95
+ readonly intentId: string;
96
+ readonly diagnosticsFailed: boolean;
97
+ readonly code = "outcome-unknown";
98
+ constructor(intentId: string, diagnosticsFailed?: boolean);
99
+ }
100
+ export declare function sameScope(a: AuditScope, b: AuditScope): boolean;
101
+ /** Recorded time is storage metadata, not part of an event's idempotency content. */
102
+ export declare function sameEvent(a: AuditEvent, b: AuditEvent): boolean;
@@ -0,0 +1,6 @@
1
+ import type { AuditReporter } from "./contracts";
2
+ export declare function createAuditReporter(options: {
3
+ logger: {
4
+ warn(fields: Record<string, unknown>, message: string): unknown;
5
+ };
6
+ }): AuditReporter;
@@ -0,0 +1,22 @@
1
+ // src/diagnostics.ts
2
+ import { metrics } from "@opentelemetry/api";
3
+ function createAuditReporter(options) {
4
+ const failures = metrics.getMeter("@lenso/audit").createCounter("lenso.audit.write_failures", {
5
+ description: "Audit persistence failures; no event or identity labels"
6
+ });
7
+ return (diagnostic) => {
8
+ const fields = {
9
+ mode: diagnostic.mode,
10
+ stage: diagnostic.stage,
11
+ code: diagnostic.code
12
+ };
13
+ try {
14
+ failures.add(1, fields);
15
+ } finally {
16
+ options.logger.warn(fields, "Audit persistence failed");
17
+ }
18
+ };
19
+ }
20
+ export {
21
+ createAuditReporter
22
+ };
@@ -0,0 +1,19 @@
1
+ import {
2
+ AuditError2,
3
+ sameEvent2
4
+ } from "./index-dxwdr6tz.js";
5
+
6
+ // src/repository-helpers.ts
7
+ function tenantKey(scope) {
8
+ return JSON.stringify(scope.tenantId);
9
+ }
10
+ function decodeEvent(value) {
11
+ return JSON.parse(value);
12
+ }
13
+ function insertionResult(event, existing) {
14
+ if (!existing)
15
+ throw new AuditError2("storage-failed");
16
+ return sameEvent2(event, existing) ? "duplicate" : "conflict";
17
+ }
18
+
19
+ export { tenantKey, decodeEvent, insertionResult };
@@ -0,0 +1,46 @@
1
+ // src/contracts.ts
2
+ class AuditError2 extends Error {
3
+ code;
4
+ constructor(code) {
5
+ super(`Audit ${code}`);
6
+ this.code = code;
7
+ this.name = "AuditError";
8
+ }
9
+ }
10
+
11
+ class AuditOutcomeUnknownError2 extends Error {
12
+ intentId;
13
+ diagnosticsFailed;
14
+ code = "outcome-unknown";
15
+ constructor(intentId, diagnosticsFailed = false) {
16
+ super("Effect may have occurred; audit outcome is unknown and requires reconciliation");
17
+ this.intentId = intentId;
18
+ this.diagnosticsFailed = diagnosticsFailed;
19
+ this.name = "AuditOutcomeUnknownError";
20
+ }
21
+ }
22
+ function sameScope2(a, b) {
23
+ return a.tenantId === b.tenantId && a.scopeId === b.scopeId;
24
+ }
25
+ function sameEvent2(a, b) {
26
+ const content = (event) => ({
27
+ id: event.id,
28
+ occurredAt: event.occurredAt,
29
+ scope: { tenantId: event.scope.tenantId, scopeId: event.scope.scopeId },
30
+ actor: event.actor.kind === "system" ? { kind: event.actor.kind, systemId: event.actor.systemId } : {
31
+ kind: event.actor.kind,
32
+ realmId: event.actor.realmId,
33
+ subjectId: event.actor.subjectId
34
+ },
35
+ action: event.action,
36
+ target: { type: event.target.type, id: event.target.id },
37
+ result: event.result,
38
+ reasonCode: event.reasonCode,
39
+ correlationId: event.correlationId ?? null,
40
+ summary: Object.fromEntries(Object.entries(event.summary).sort(([left], [right]) => left.localeCompare(right))),
41
+ relation: event.relation ? { kind: event.relation.kind, eventId: event.relation.eventId } : null
42
+ });
43
+ return JSON.stringify(content(a)) === JSON.stringify(content(b));
44
+ }
45
+
46
+ export { AuditError2, AuditOutcomeUnknownError2, sameScope2, sameEvent2 };