@voltro/plugin-audit 0.36.0 → 0.38.0

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/CHANGELOG.md CHANGED
@@ -39,6 +39,188 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.38.0] — 2026-08-14
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **@voltro/plugin-audit, @voltro/protocol, @voltro/plugin-auth** — An audit row now says when an action was taken through an IMPERSONATED session, on the default settings, and no redactor can take that away.
47
+
48
+ `AuditEvent` gained `impersonation`, and the datastore sink a nullable json column of the same name. The mark is lifted out of `subject.metadata` BEFORE the redaction chain runs, so `redactSubject` — including a custom function that erases the subject wholesale — never gets a say.
49
+
50
+ **The defect it closes.** `@voltro/plugin-auth` mints the mark into `subject.metadata`, and `auditPlugin`'s default `redactSubject: 'metadata'` replaces that whole bag. That default is right: the bag is where a per-user provider credential lands, and an audit table is the last place a live PAT should be. The consequence was that on defaults an impersonated action was recorded indistinguishably from the user's own — the one distinction an audit trail exists to make. The documented mitigation (`redactSubject: impersonationAuditRedactor()`) worked and was opt-in, and an audit property that depends on somebody wiring it is not a property.
51
+
52
+ The alternative fix — a keep-these-keys option on `redactSubject` — was rejected for the same reason: it leaves the default wrong, and "who really did this" is not the app's metadata to configure away. It is a property of the event, so it is now a field of the event.
53
+
54
+ **BREAKING: `IMPERSONATION_METADATA_KEY` moved from `@voltro/plugin-auth` to `@voltro/protocol`.** It names the one reserved key in `Subject.metadata`, and `Subject` is protocol's type. Two packages need it — plugin-auth writes the mark, plugin-audit reads it — and a plugin must not depend on another plugin, so spelling the string in both would have made it a second definition no guard is watching. Everything else stays: plugin-auth still exports `impersonationOf`, `isImpersonated`, `ImpersonationMark` and `impersonationAuditRedactor`. The codemod repoints the import, preserving an alias and the type-only form.
55
+
56
+ The redactor keeps working and is no longer load-bearing. Set `redactSubject: 'none'` or a custom function for your own reasons; the impersonation mark is recorded either way.
57
+
58
+ No migration is needed for the new column — a `_voltro_*` change rides the declarative differ on `voltro db apply` and on a `voltro dev` boot, on every dialect.
59
+ - **@voltro/cli** — A `*.startup.ts` that fails now refuses the boot. It used to warn and let the server come up.
60
+
61
+ Measured, on a real `voltro dev` against postgres, while building the row-filter integration test in this same release. A startup reached for `ctx.store.select(...)` — a builder that lives on the request-scoped `MutationStore`, not on the `DataStore` a startup receives — and threw on its first line. The boot printed:
62
+
63
+ ```
64
+ warn startup: function rejected
65
+ info startup: registered <- next line, same file
66
+ ```
67
+
68
+ and then served every request with no row filter registered. Two lines contradicting each other, the second asserting exactly the thing that had just failed, and an app that looked healthy while its access control was absent.
69
+
70
+ **The rule this overturns was right when it was written.** The header of `startupRunner.ts` read "Errors are logged but never fatal — a failing startup MUST NOT block the rest of the app", and for the startups it was written for — an SSE bridge, a sync loop, a metric aggregator — that is the correct call. It stopped being right when a startup became the documented seam for REGISTRATION: `setRowFilter` is installed from one.
71
+
72
+ The runner cannot tell a registration from a background loop, and the two failures are not symmetric. "The app refuses to boot" is fixed in seconds and is visible to everyone; "the app serves without its access control" is visible to nobody. So the default is the recoverable one, and an app that genuinely wants best-effort writes the `try`/`catch` inside its own startup — one line, at the site where somebody decided the failure was acceptable, where a reviewer can see it. Deliberately not a flag: a flag moves that decision away from the startup it applies to and makes it one setting for all of them.
73
+
74
+ **Two sibling silences went with it**, because fixing only the rejection would have left two more ways to reach the identical state — the per-seam shape this repo keeps paying for. A startup file that cannot be imported, and one with no default-exported function, used to warn and skip. Both refuse now. The second matters more than it sounds: the convention is a DEFAULT-exported function, a named export is discovered and never runs, and that is indistinguishable from a startup that ran and did nothing.
75
+
76
+ **And the 2-second race is gone, which is the half that made the rest reliable.** The old code did not wait for a startup to settle — it raced it against 2000 ms and let the boot win. A startup slower than that was reported as fine, so a rejection arriving afterwards had nothing left to refuse. The runner now waits for the startup to SETTLE, so every failure is catchable however slow it is.
77
+
78
+ That race existed to protect one shape: a startup that never returns because it holds a fiber until shutdown. Counted before changing it, that shape appears in ZERO of the four startups this framework ships — `warm.startup.tsx` (twice), `searchBackfill.startup.tsx` and the memory fixture's all return. So the race protected the shape we discourage and penalised the shape we teach, and the penalised one includes `searchBackfill`, our own example, which awaits a full-table query plus an index backfill and is the likeliest thing in the box to exceed two seconds. Slow AND failing put a consumer back in exactly the silent state this release removes.
79
+
80
+ **So a startup that never returns now refuses the boot too**, after `VOLTRO_STARTUP_TIMEOUT_MS` (60s default), with a message naming the file and showing the `onShutdown` shape to use instead. That makes a previously-documented capability illegal — "a fiber that resolves only on shutdown" — and it is the deliberate half of this change rather than a side effect. A startup that is merely SLOW is unaffected: it is waited for and registers when it finishes.
81
+
82
+ `startup: registered` is written only when the function actually returned. The old code printed it for a failed startup and for one still running.
83
+
84
+ Verified against a real process in every direction, not only in units: the reintroduced defect exits 1 and never listens; a never-returning startup exits 1 and never listens; a slow-but-successful one still registers; and the healthy fixture boots and serves all eight row-filter assertions. `startupRunner.test.ts` is new — there were no tests on this runner at all, which is part of why the contradiction survived.
85
+
86
+ **`voltro update` carries you across this** — codemod `0.38.0/02_startup-failure-refuses-boot`.
87
+
88
+ ### Fixed
89
+
90
+ - **@voltro/cli** — A table declared through `databaseHandle({ … })` but not exported as a top-level table is now DISCOVERED — so its mixins apply.
91
+
92
+ Discovery kept whatever `isTable()` accepted out of a schema module's `Object.values`, which only ever sees tables the file exports DIRECTLY. A schema that builds its tables programmatically exports the builder's result:
93
+
94
+ ```ts
95
+ export const blogEntities = contentTypeToEntities(blogPost) // { draft, published }
96
+ export const database = databaseHandle({ …, blogPostDrafts: blogEntities.draft })
97
+ ```
98
+
99
+ `isTable({ draft, published })` is false, so those tables never entered the schema registry — while `databaseHandle` had registered them for by-name lookup perfectly well.
100
+
101
+ **Two registries with different populations, read by different things.** `getTable()` found the table, so insert validation knew `tenantId` was NOT NULL. The schema registry is what drives the mixins, so nothing stamped it. Measured against a real `voltro dev` boot of the scaffolded `api-cms` template, over real RPC:
102
+
103
+ ```
104
+ TableValidationFailed: { "table": "blogPost_drafts", "summary":
105
+ "missing required column 'tenantId' — NOT NULL with no default and not auto-stamped" }
106
+ ```
107
+
108
+ Every `content.saveDraft` in that template, since it shipped. Its own unit test could not see it, because the test builds its schema registry by hand — the one step the running app does not perform.
109
+
110
+ **The write failing was the lucky half.** Reads do not announce themselves: `makeQueryFinalizer` AND-merges `tenantId` from this same registry, so a table missing from it is a table nobody scopes. The visible symptom was a broken mutation; the invisible one was tenant isolation quietly not applied to those tables.
111
+
112
+ The fix takes the by-name registry's DELTA across each schema module's import, so a handle-declared table is attributed to the file that declared it — rather than reading the whole global registry, which by then also holds framework tables that this set deliberately excludes. It lives in `loadDiscovered`, which `voltro dev` and `voltro serve` both call, so the two cannot disagree about it.
113
+
114
+ No app change is needed; the idiomatic `databaseHandle` declaration now works as its documentation always said.
115
+ - **@voltro/database, @voltro/cli** — A migration that fails because `DB_SCHEMA` names a schema that does not exist now says so.
116
+
117
+ Postgres answers `3F000 no schema has been selected to create in`, and the only thing that puts a non-default schema on the connection's `search_path` here is `DB_SCHEMA`. The error mentions neither the variable nor the schema, so the FIRST `CREATE TABLE` of the boot fails with a message about SQL and the reader goes looking at the DDL — for a typo in an environment variable.
118
+
119
+ Found while building the row-filter integration test below: the boot aborted, the log named `_voltro_migrations` and a postgres routine, and nothing in it pointed at the one line of configuration that caused it. Same shape as a 403 that says nothing about `VOLTRO_INSPECT_TOKEN`, fixed in the same release.
120
+
121
+ The remedy also states what the framework will NOT do: Voltro creates tables, never the schema itself. That is a namespace decision, and inventing one from a typo puts a migration somewhere nobody is looking.
122
+
123
+ `remedyFor` is deliberately a one-entry map and the test asserts the empty case as well as the full one. A remedy is worth printing only where the mapping from driver code to cause is exact; a list of maybes is how a reader learns to skip the section.
124
+
125
+ **Alongside it: the row filter is now driven under `voltro dev` against a real postgres.** Both defects that reached consumers lived between layers that were each individually covered — a module-local `let` that split per instance, then a query producer that built its context from an unscoped request — and three consumer documents carried "we have not run this against a real database" as an honest caveat. `rowFilterDevPostgres.integration.test.ts` boots the fixture twice: handlers with no predicate of their own, a filter registered from a `*.startup.tsx`, and a NEGATIVE CONTROL run with the registration skipped that asserts the same queries return BOTH owners. Without the control, "the caller saw one owner" is equally consistent with a database that only ever held one.
126
+ - **@voltro/cli** — A shard that cannot be released no longer spins a fiber at full speed. Upstream `@effect/cluster` releases shards through `Effect.eventually` — retry until success, zero delay, logged at DEBUG — so a shard whose storage was unreachable retried as fast as the event loop allowed, in the one place nobody watches.
127
+
128
+ Our patch replaces it with `Effect.retry(Schedule.exponential(100) ∪ Schedule.spaced(5000))`. `union` takes the MINIMUM of the two delays, so the 5s spacing caps the backoff instead of compounding with it, and both schedules recur forever — attempts stay unbounded, which is what the original intended. The persistence was always correct; only the missing delay was the defect.
129
+
130
+ This entry exists because the change would otherwise have shipped undocumented. The patch lives in `packages/cli/templates/patches/`, and the changelog gate requires an entry for `packages/*/src` — so nothing would have demanded one, even though the file is installed into every user project that runs cluster. A rule that cannot see a path is not the same as a path with nothing on it.
131
+
132
+ ---
133
+
134
+ ## [0.37.0] — 2026-08-13
135
+
136
+ ### ⚠ BREAKING
137
+
138
+ - **@voltro/protocol, @voltro/runtime, @voltro/cli** — A field a procedure's input schema does not declare now REJECTS the call. It used to be discarded and the call ran with what was left.
139
+
140
+ The measurement, from a consumer's root layout:
141
+
142
+ ```ts
143
+ query?.('userSettings.list', { employeeId })
144
+ ```
145
+
146
+ That procedure declares `userId` / `userIdIn`. Effect's default `onExcessProperty: 'ignore'` decoded the payload to `{}` — not reasoned, measured:
147
+
148
+ ```ts
149
+ decodeUnknownSync(Struct({ userId: optional(String) }))({ employeeId: 'e' }) // → {}
150
+ ```
151
+
152
+ An empty input to a LIST query is not a narrower filter, it is the ABSENCE of one. Their admin, signed in as `2d0add2c…`, was served the settings row of `4410c2f8…` — another user's language and theme in the first paint, with nothing in any log to say so.
153
+
154
+ **Why refuse rather than warn.** The decoder cannot tell a projection field from a FILTER field, and that asymmetry is the whole risk: dropping an unknown `include` costs a caller some data, dropping an unknown `tenantId` hands them somebody else's. Nothing at decode time distinguishes the two, so the safe direction is the only one available — the same fail-closed reasoning as the row filter's refusal, one layer up. A warning would have to be read by someone, in a log, after the wrong rows were already served.
155
+
156
+ The typed loader query that shipped in 0.36.0 closes the same hole for callers we compile. This closes it for the ones we do not: a plain `fetch`, a curl, a still-cached bundle after a field rename, and every untyped caller.
157
+
158
+ Three things measured rather than assumed, because none follows from the annotation's name: it propagates into NESTED structs, through every member of a UNION, and leaves a non-struct payload (`Schema.Void`, a scalar) alone.
159
+
160
+ **`Schema.Struct({})` needed a filter, and only a real process showed it.** The fixture's `notes.list` declares an empty input; `POST /rpc` with `{ employeeId }` came back `200` with a snapshot, which for twenty minutes read as the whole change having failed. An empty `TypeLiteral` has no property signatures, so Effect has no expected key set for a key to be excess OF — self-consistent, and the wrong answer here, because `input: Schema.Struct({})` is the STRONGEST declaration a procedure can make and it was the one shape that accepted everything. It gets an explicit predicate now; `Schema.Record` keeps its open key set, because there the openness is declared.
161
+
162
+ **Verified against a running `voltro serve`, not only in units.** A declared input succeeds and inserts its row; an undeclared field is refused naming the key and the accepted set. The refusal arrives on the channel a payload decode failure ALREADY used — a missing required field produces the same `Die` with a `ParseError` message — so this adds no new error shape for a client to handle, it moves one case onto the channel the sibling case was always on.
163
+
164
+ `strictInput` lives in one module and every `Rpc.make` payload in `@voltro/protocol` goes through it — query, mutation, action, stream, event, plus the workflow start on both the server lifter and the browser-loaded rpc group. `strictInput.test.ts` asserts that SET by scanning the source, not the five lifters somebody remembered: a rule applied at the sites you can list is the shape that let `bootStoreCodec` be fixed twice and break a third time.
165
+
166
+ **`voltro update` carries you across this** — codemod `0.37.0/01_procedure-input-rejects-undeclared-fields`, a written note. A transform would have to guess which declared field a stray one meant, which is the same guess that produced the defect.
167
+
168
+ ### Added
169
+
170
+ - **@voltro/plugin-audit** — `redactInput` / `redactOutcome` gained `'shape'`, and `redactSubject` gained `'metadata-shape'`: the payload's STRUCTURE survives, no value from it.
171
+
172
+ ```json
173
+ { "__redacted": { "jiraToken": "string(113)", "attempts": "number" } }
174
+ ```
175
+
176
+ Requested by the consumer who had asked for the redaction one round earlier, and both requests were right. They spent a day on a bug their own audit trail could have ended in seconds — a value arrived as 113 characters where 44 were due, and the row that would have said so read `{"__redacted":"all"}`. `'all'` remains the default on every field; this is opt-in.
177
+
178
+ The rules, and the two that are decisions rather than details:
179
+
180
+ - A string reports its LENGTH. Never a prefix, never a hash — `enc:v1:` is a prefix and so is the first byte of a private key, so there is no prefix length that is safe for every credential format. - A number, boolean or date reports its TYPE only. A number can BE the secret. - **A key can be the value.** An object keyed by user data puts a datum where a schema name belongs, so a key is reproduced only when it looks like a declared field — a short plain identifier. The first version truncated long keys and documented the weakness instead; this module's own test caught 62 characters of a secret surviving on the first run. A leak with a footnote is still a leak. - **A string's length is a real disclosure, and a small one.** Stated in the docs rather than buried: for a fixed-format credential it carries nothing, for a human-chosen password it is a weak hint. `'all'` stays the default for anyone that matters to.
181
+
182
+ The `'shape'` outcome describes the payload it REPLACES — the value on success, the error on failure — rather than the event. Describing the event would report `{ kind, value, durationMs }` and hide the field, which is the failure the option exists to end. An error's `_tag` still survives, as it does under `'all'`.
183
+
184
+ ### Fixed
185
+
186
+ - **@voltro/cli** — `voltro db --help` listed fifteen of eighteen subcommands. `adopt`, `scan-credentials` and `encrypt-column` shipped and never joined the hand-written string.
187
+
188
+ A consumer wrote both halves of that gap into a requirements document, as separate items, neither of them about help text:
189
+
190
+ - **`voltro db encrypt-column` "does not exist"** — filed as a feature request, quoting the fifteen names they saw as evidence. It shipped in 0.33.0, and enabling `.encrypted()` on a populated column by hand is exactly the migration they were about to write themselves. - **`scan-credentials` "no longer exists"** — filed as CLOSED, a credential scanner struck off their list as removed. It had not moved.
191
+
192
+ A quoted enumeration is read as exhaustive, and the more careful the reader the more thoroughly they act on the missing entry. Same lesson a boot refusal in `procedureAccessGate` had already taught us, in a place nobody thought of as a message.
193
+
194
+ The usage line is now GENERATED from the dispatch table's key type (`Record<DbSubcommand, Handler>` in `dbCommand.ts`, names in `subcommandNames.ts`), so a handler with no name or a name with no handler fails to compile. `voltro privacy` is keyed the same way. The prose summary beside it cannot be generated — it carries per-command annotations — so a test asserts it mentions every name, because it carried the identical three omissions and it is what `voltro --help` prints first.
195
+
196
+ `subcommandHelpParity.test.ts` also NAMES the six commands whose subcommand menus have no dispatch table behind them (`webhooks`, `evolve`, `new`, `data`, `storage`, `add`). They dispatch through a switch and are unchecked; a silently-unchecked command reads exactly like a checked one.
197
+ - **@voltro/cli** — A 401 or 403 from the inspect surface now names `VOLTRO_INSPECT_TOKEN` and says which side is missing.
198
+
199
+ `voltro db plan --against <url>` printed `remote returned 403` and stopped. A consumer read that as a DATABASE permission problem — the natural reading of a 403 from a command whose entire subject is a database — and went looking at grants. The cause is one unset environment variable, which the command reads four lines above the message.
200
+
201
+ `voltro probe access` had half of it: it named the variable on 401 and not on 403, while classifying both as `refused`. So the two commands somebody needs during an access migration were the two that would not say what was wrong, and one of them said something misleading instead.
202
+
203
+ `inspectGateHint` is shared by both call sites and distinguishes the two statuses, because they call for different actions: a 401 means no credential was sent (set the variable), a 403 means the one sent was not accepted (the two values differ). Both halves of the sentence name the server AND the calling shell — naming one side produces a second failed attempt.
204
+ - **@voltro/cli** — `VOLTRO_TEMPLATES_DIR` is authoritative when set. It used to be a HINT: if the path it named held no `apps/` (or no `baselines/`), both resolvers fell through to the sibling-checkout walk-up and quietly used a different tree — or none.
205
+
206
+ A pointer that silently isn't followed is worse than a wrong one. A CI job aimed at the wrong path scaffolded from whatever it happened to find, and a job whose checkout had failed reported an empty template catalogue with nothing connecting that emptiness to the variable it was given. `scripts/lib/docsSite.mjs` states the same rule for `VOLTRO_DOCS_DIR`, and arrived at it the same way: you said where it is; it is not there.
207
+
208
+ Behaviourally this only changes the misconfigured case — a correct `VOLTRO_TEMPLATES_DIR` resolved to the same place before and after. What changes is that a wrong one now shows up as "not found, here is the path I was told" at the first thing that reads it, instead of as a different tree three steps later.
209
+
210
+ The unbundled resolution order is otherwise untouched: sibling `voltro-templates` → `.voltro-templates` → the bundled `templates/` a published CLI ships.
211
+
212
+ ### Internal (no consumer-facing effect)
213
+
214
+ - **@voltro/plugin-ai-flows** — Two comments in the flow engine cited task records from a plans tracker that has since been deleted. Comment-only; no behavior, no API, nothing a consumer can observe.
215
+
216
+ Worth writing down because of HOW it surfaced. The tracker was retired in the META repo, and the gate that went red was in THIS one — `check-stale-task-comments.mjs` resolves a comment's `task #NN` against `../plans`, so deleting a plan document in one repo can only be half a change, and the other half is in a repo the deleting commit never touched.
217
+
218
+ Neither comment was WRONG, which is the part that makes the rule earn its keep. The first claims `@voltro/ai` has first-class media generation — true: `generateImage`, `generateSpeech`, `generateVideo` all ship in `packages/ai/src/media.ts`. It now names those three instead of a record number, which is checkable without the deleted document. The second only quoted the retired id inside its own account of a defect (a `"not yet wired (task #35)"` message that outlived the shipped HITL park and misled an audit into filing it as unbuilt); the quote lost the number and kept the whole lesson.
219
+
220
+ The check's own failure text is the reasoning: a plan is retired for exactly two reasons — the work shipped, or it was dropped without shipping — and a comment still citing it asserts the second while usually meaning the first.
221
+
222
+ ---
223
+
42
224
  ## [0.36.0] — 2026-08-13
43
225
 
44
226
  ### ⚠ BREAKING
package/dist/index.d.ts CHANGED
@@ -127,6 +127,27 @@ export declare interface AuditEvent {
127
127
  readonly scope?: unknown;
128
128
  /** The app's own note about what happened. Opaque, never interpreted. */
129
129
  readonly metadata?: unknown;
130
+ /**
131
+ * The impersonation mark, when the acting session was an impersonated one —
132
+ * absent otherwise.
133
+ *
134
+ * **A FIRST-CLASS FIELD, on purpose, and no redactor can reach it.** The mark
135
+ * is minted into `subject.metadata` by `@voltro/plugin-auth`, and the audit
136
+ * default `redactSubject: 'metadata'` replaces that whole bag — correctly, it
137
+ * is where a per-user provider credential lands. The consequence was that on
138
+ * DEFAULTS an impersonated action was indistinguishable from the user's own,
139
+ * which is the one distinction an audit trail exists to make.
140
+ *
141
+ * Fixing that with a keep-these-keys option would have left it opt-in, and
142
+ * "who really did this" is not the app's metadata to configure away — it is a
143
+ * property of the event. So it is lifted out of the subject BEFORE any
144
+ * redaction runs, and `redactSubject` is not given a say.
145
+ *
146
+ * Opaque here (`unknown`): `@voltro/plugin-audit` copies the mark, it does not
147
+ * interpret it. `impersonationOf()` from `@voltro/plugin-auth` is what turns
148
+ * it back into a typed `ImpersonationMark`.
149
+ */
150
+ readonly impersonation?: unknown;
130
151
  readonly traceId: string;
131
152
  readonly input: unknown;
132
153
  readonly outcome: {
@@ -325,6 +346,10 @@ export declare interface AuditPluginOptions {
325
346
  *
326
347
  * - `'all'` (DEFAULT) — the payload is replaced by `{ __redacted: 'all' }`.
327
348
  * The row still proves a payload existed; it just does not carry it.
349
+ * - `'shape'` — the payload's STRUCTURE, no value from it:
350
+ * `{ __redacted: { token: 'string(113)', limit: 'number' } }`. See
351
+ * `redactionShape.ts` for the rules and for the one real trade (a
352
+ * string's LENGTH is disclosed).
328
353
  * - `'none'` — the raw input, verbatim. What every sink did before this
329
354
  * option existed.
330
355
  * - a function — `(event) => unknown`, for field-level control.
@@ -345,13 +370,17 @@ export declare interface AuditPluginOptions {
345
370
  * row; leaking a credential is not visible at all. Opt in per app with a
346
371
  * function once you know your own inputs.
347
372
  */
348
- readonly redactInput?: 'all' | 'none' | ((event: AuditEvent) => unknown);
373
+ readonly redactInput?: 'all' | 'shape' | 'none' | ((event: AuditEvent) => unknown);
349
374
  /**
350
375
  * What happens to `AuditEvent.subject` before it is handed to the sink.
351
376
  *
352
377
  * - `'metadata'` (DEFAULT) — `subject.metadata` is replaced by
353
378
  * `{ __redacted: 'all' }`. `type`, `id`, `tenantId` and `scopes` survive,
354
379
  * which is everything the trail is actually read for.
380
+ * - `'metadata-shape'` — the same, but `metadata`'s STRUCTURE survives
381
+ * instead of nothing. Useful for the case this whole option exists for:
382
+ * seeing THAT a credential-shaped value sits in the bag, and how long it
383
+ * is, without recording it.
355
384
  * - `'none'` — the subject verbatim. What every sink did before this option
356
385
  * existed.
357
386
  * - a function — `(subject) => unknown`, for field-level control.
@@ -379,8 +408,18 @@ export declare interface AuditPluginOptions {
379
408
  * `resolveScope` still sees the LIVE subject, so a scope derived from
380
409
  * `metadata` keeps working — redaction applies to what is STORED, not to what
381
410
  * the plugin can compute.
411
+ *
412
+ * KNOWN DEBT, on the DEFAULT. An impersonation mark lives in `metadata`, so
413
+ * under `'metadata'` a durable trail records an impersonated action
414
+ * indistinguishably from the user's own — the one distinction an audit trail
415
+ * exists to make. `impersonationAuditRedactor()` plus an always-on grant row
416
+ * mitigates it, and `'metadata-shape'` narrows it further (the KEY survives,
417
+ * so the row at least says an impersonation mark was there). Neither is the
418
+ * fix. The fix is a keep-these-keys option, or a first-class
419
+ * `AuditEvent.impersonation` field that the redactors cannot reach — because
420
+ * "who really did this" is not app-controlled metadata, it is the event.
382
421
  */
383
- readonly redactSubject?: 'metadata' | 'none' | ((subject: Subject) => unknown);
422
+ readonly redactSubject?: 'metadata' | 'metadata-shape' | 'none' | ((subject: Subject) => unknown);
384
423
  /**
385
424
  * What happens to `AuditEvent.outcome`'s payload before it is handed to the
386
425
  * sink.
@@ -388,6 +427,10 @@ export declare interface AuditPluginOptions {
388
427
  * - `'all'` (DEFAULT) — `outcome.value` on success, and `outcome.error` on
389
428
  * failure, are replaced by `{ __redacted: 'all' }`. `kind`, `durationMs`
390
429
  * and the error's TAG survive, which is what the trail is read for.
430
+ * - `'shape'` — the STRUCTURE survives instead of nothing. This is the
431
+ * option a consumer asked for after a day spent on a bug their own trail
432
+ * could have ended: a value arrived as 113 characters where 44 were due,
433
+ * and the row that would have said so read `{"__redacted":"all"}`.
391
434
  * - `'none'` — the outcome verbatim. What every sink did before this
392
435
  * option existed.
393
436
  * - a function — `(event) => unknown`, for field-level control.
@@ -419,7 +462,7 @@ export declare interface AuditPluginOptions {
419
462
  * A `record` predicate still sees the LIVE outcome, so a filter that keys on
420
463
  * the result keeps working — redaction applies to what is STORED.
421
464
  */
422
- readonly redactOutcome?: 'all' | 'none' | ((event: AuditEvent) => unknown);
465
+ readonly redactOutcome?: 'all' | 'shape' | 'none' | ((event: AuditEvent) => unknown);
423
466
  }
424
467
 
425
468
  /** The narrow read surface the entry points need. */
package/dist/index.js CHANGED
@@ -1,11 +1,29 @@
1
1
  import { audit as e } from "./mixin.js";
2
2
  import { Effect as t } from "effect";
3
- import { definePlugin as n, pluginInstanceName as r } from "@voltro/protocol";
4
- import { createLogger as i } from "@voltro/logger";
5
- import { and as a, eq as o, id as s, integer as c, json as l, registerRetention as u, resolveActorSnapshot as d, retentionTtlMsFromEnv as f, table as p, text as m, timestamp as h } from "@voltro/database";
6
- import { createHash as g, createHmac as _, randomBytes as v } from "node:crypto";
7
- //#region src/tableName.ts
8
- var y = "_voltro_audit_log", b = [
3
+ import { definePlugin as n, pluginInstanceName as r, rawImpersonationMark as i } from "@voltro/protocol";
4
+ import { createLogger as a } from "@voltro/logger";
5
+ import { and as o, eq as s, id as c, integer as l, json as u, registerRetention as d, resolveActorSnapshot as f, retentionTtlMsFromEnv as p, table as m, text as h, timestamp as g } from "@voltro/database";
6
+ import { createHash as _, createHmac as v, randomBytes as y } from "node:crypto";
7
+ //#region src/redactionShape.ts
8
+ var b = 4, x = 32, S = 64, C = /^[A-Za-z_$][A-Za-z0-9_$]*$/, w = (e) => e.length <= S && C.test(e) ? e : `<key:string(${e.length})>`, T = (e, t = 0) => {
9
+ if (e === null) return "null";
10
+ if (e === void 0) return "undefined";
11
+ let n = typeof e;
12
+ if (n === "string") return `string(${e.length})`;
13
+ if (n === "number" || n === "boolean" || n === "bigint" || n === "symbol" || n === "function") return n;
14
+ if (e instanceof Date) return "date";
15
+ if (t >= b) return Array.isArray(e) ? "array(…)" : "object(…)";
16
+ if (Array.isArray(e)) {
17
+ let n = e.slice(0, x).map((e) => T(e, t + 1));
18
+ return e.length > x ? [...n, `…${e.length - x} more`] : n;
19
+ }
20
+ if (n === "object") {
21
+ let n = Object.entries(e), r = {};
22
+ for (let [e, i] of n.slice(0, x)) r[w(e)] = T(i, t + 1);
23
+ return n.length > x && (r["…"] = `${n.length - x} more key(s)`), r;
24
+ }
25
+ return n;
26
+ }, E = (e) => ({ __redacted: T(e) }), D = "_voltro_audit_log", O = [
9
27
  "tag",
10
28
  "at",
11
29
  "subjectId",
@@ -20,26 +38,26 @@ var y = "_voltro_audit_log", b = [
20
38
  "input",
21
39
  "outcome",
22
40
  "errorTag"
23
- ], x = (e) => {
41
+ ], k = (e) => {
24
42
  if (e == null) return "null";
25
43
  if (e instanceof Date) return JSON.stringify(e.toISOString());
26
44
  let t = typeof e;
27
- return t === "number" || t === "boolean" || t === "string" ? JSON.stringify(e) : t === "bigint" ? JSON.stringify(String(e)) : Array.isArray(e) ? `[${e.map(x).join(",")}]` : t === "object" ? `{${Object.entries(e).filter(([, e]) => e !== void 0).sort(([e], [t]) => e < t ? -1 : +(e > t)).map(([e, t]) => `${JSON.stringify(e)}:${x(t)}`).join(",")}}` : JSON.stringify(String(e));
28
- }, S = (e) => e instanceof Date ? e.toISOString() : e, C = (e) => x(Object.fromEntries(b.map((t) => [t, S(e[t] ?? null)]))), w = (e) => {
45
+ return t === "number" || t === "boolean" || t === "string" ? JSON.stringify(e) : t === "bigint" ? JSON.stringify(String(e)) : Array.isArray(e) ? `[${e.map(k).join(",")}]` : t === "object" ? `{${Object.entries(e).filter(([, e]) => e !== void 0).sort(([e], [t]) => e < t ? -1 : +(e > t)).map(([e, t]) => `${JSON.stringify(e)}:${k(t)}`).join(",")}}` : JSON.stringify(String(e));
46
+ }, A = (e) => e instanceof Date ? e.toISOString() : e, j = (e) => k(Object.fromEntries(O.map((t) => [t, A(e[t] ?? null)]))), M = (e) => {
29
47
  let t = process.env.VOLTRO_AUDIT_CHAIN_SECRET;
30
- return t ? _("sha256", t).update(e, "utf8").digest("hex") : g("sha256").update(e, "utf8").digest("hex");
31
- }, T = (e, t) => w(JSON.stringify([
48
+ return t ? v("sha256", t).update(e, "utf8").digest("hex") : _("sha256").update(e, "utf8").digest("hex");
49
+ }, N = (e, t) => M(JSON.stringify([
32
50
  t.chainId,
33
51
  t.seq,
34
52
  t.prevHash,
35
- C(e)
36
- ])), E = (e = v(12).toString("base64url")) => {
53
+ j(e)
54
+ ])), P = (e = y(12).toString("base64url")) => {
37
55
  let t = 0, n = null;
38
56
  return {
39
57
  chainId: e,
40
58
  link: (r) => {
41
59
  t += 1;
42
- let i = T(r, {
60
+ let i = N(r, {
43
61
  chainId: e,
44
62
  seq: t,
45
63
  prevHash: n
@@ -53,9 +71,9 @@ var y = "_voltro_audit_log", b = [
53
71
  return n = i, a;
54
72
  }
55
73
  };
56
- }, D = async (e, t = {}) => {
57
- let n = t.chainId === void 0 ? void 0 : o("chainId", t.chainId), r = await e.query({
58
- table: y,
74
+ }, F = async (e, t = {}) => {
75
+ let n = t.chainId === void 0 ? void 0 : s("chainId", t.chainId), r = await e.query({
76
+ table: D,
59
77
  ...n ? { predicate: n } : {},
60
78
  order: [{
61
79
  column: "chainId",
@@ -65,7 +83,7 @@ var y = "_voltro_audit_log", b = [
65
83
  direction: "asc"
66
84
  }],
67
85
  ...t.limit === void 0 ? {} : { take: t.limit }
68
- }), i = [], a = [], s = 0, c = 0, l = null, u = () => {
86
+ }), i = [], a = [], o = 0, c = 0, l = null, u = () => {
69
87
  l &&= (a.push({
70
88
  chainId: l.chainId,
71
89
  from: l.from,
@@ -77,16 +95,16 @@ var y = "_voltro_audit_log", b = [
77
95
  for (let e of r) {
78
96
  let t = e.chainId, n = e.hash, r = e.seq;
79
97
  if (typeof t != "string" || typeof n != "string" || r == null) {
80
- s += 1;
98
+ o += 1;
81
99
  continue;
82
100
  }
83
- let a = Number(r), o = typeof e.prevHash == "string" ? e.prevHash : null, d = typeof e.id == "string" ? e.id : void 0;
101
+ let a = Number(r), s = typeof e.prevHash == "string" ? e.prevHash : null, d = typeof e.id == "string" ? e.id : void 0;
84
102
  c += 1, !l || l.chainId !== t ? (u(), l = {
85
103
  chainId: t,
86
104
  from: a,
87
105
  last: a,
88
106
  tip: n
89
- }) : (a === l.last + 1 ? o !== l.tip && i.push({
107
+ }) : (a === l.last + 1 ? s !== l.tip && i.push({
90
108
  kind: "broken-link",
91
109
  chainId: t,
92
110
  seq: a,
@@ -97,10 +115,10 @@ var y = "_voltro_audit_log", b = [
97
115
  chainId: t,
98
116
  seq: l.last + 1,
99
117
  detail: `chain jumps from seq ${l.last} to ${a} — ${a - l.last - 1} row(s) missing`
100
- }), l.last = a, l.tip = n), T(e, {
118
+ }), l.last = a, l.tip = n), N(e, {
101
119
  chainId: t,
102
120
  seq: a,
103
- prevHash: o
121
+ prevHash: s
104
122
  }) !== n && i.push({
105
123
  kind: "tampered",
106
124
  chainId: t,
@@ -112,31 +130,32 @@ var y = "_voltro_audit_log", b = [
112
130
  return u(), {
113
131
  ok: i.length === 0,
114
132
  rowsChecked: c,
115
- unchainedRows: s,
133
+ unchainedRows: o,
116
134
  chains: a,
117
135
  issues: i,
118
136
  keyed: !!process.env.VOLTRO_AUDIT_CHAIN_SECRET
119
137
  };
120
- }, O = p(y, {
121
- id: s({ prefix: "audit" }),
122
- tag: m(),
123
- at: h(),
124
- subjectId: m().nullable(),
125
- tenantId: m().nullable(),
126
- traceId: m(),
127
- status: m(),
128
- durationMs: m(),
129
- subject: l(),
130
- actor: l().nullable(),
131
- scope: l().nullable(),
132
- metadata: l().nullable(),
133
- input: l(),
134
- outcome: l(),
135
- errorTag: m().nullable(),
136
- chainId: m().nullable(),
137
- seq: c().nullable(),
138
- prevHash: m().nullable(),
139
- hash: m().nullable()
138
+ }, I = m(D, {
139
+ id: c({ prefix: "audit" }),
140
+ tag: h(),
141
+ at: g(),
142
+ subjectId: h().nullable(),
143
+ tenantId: h().nullable(),
144
+ traceId: h(),
145
+ status: h(),
146
+ durationMs: h(),
147
+ subject: u(),
148
+ actor: u().nullable(),
149
+ scope: u().nullable(),
150
+ metadata: u().nullable(),
151
+ impersonation: u().nullable(),
152
+ input: u(),
153
+ outcome: u(),
154
+ errorTag: h().nullable(),
155
+ chainId: h().nullable(),
156
+ seq: l().nullable(),
157
+ prevHash: h().nullable(),
158
+ hash: h().nullable()
140
159
  }).index("byAuditTag", ["tag"]).index("byAuditTrace", ["traceId"]).index("byAuditSubjectStatus", [
141
160
  "subjectId",
142
161
  "status",
@@ -145,7 +164,7 @@ var y = "_voltro_audit_log", b = [
145
164
  "tenantId",
146
165
  "status",
147
166
  "at"
148
- ]).index("byAuditChain", ["chainId", "seq"]), k = [O], A = (e) => ({
167
+ ]).index("byAuditChain", ["chainId", "seq"]), L = [I], R = (e) => ({
149
168
  tag: e.tag,
150
169
  at: new Date(e.ts),
151
170
  subjectId: e.subject.id ?? null,
@@ -157,45 +176,46 @@ var y = "_voltro_audit_log", b = [
157
176
  actor: e.actor ?? null,
158
177
  scope: e.scope ?? null,
159
178
  metadata: e.metadata ?? null,
179
+ impersonation: e.impersonation ?? null,
160
180
  input: e.input,
161
181
  outcome: e.outcome,
162
- errorTag: e.outcome.kind === "error" ? j(e.outcome.error) : null
163
- }), j = (e) => {
182
+ errorTag: e.outcome.kind === "error" ? z(e.outcome.error) : null
183
+ }), z = (e) => {
164
184
  if (typeof e != "object" || !e) return null;
165
185
  let t = e._tag;
166
186
  return typeof t == "string" && t !== "" ? t : null;
167
- }, M = async (e, t) => e.query({
168
- table: y,
169
- predicate: o("traceId", t),
187
+ }, B = async (e, t) => e.query({
188
+ table: D,
189
+ predicate: s("traceId", t),
170
190
  order: [{
171
191
  column: "at",
172
192
  direction: "asc"
173
193
  }]
174
- }), N = async (e, t, n = {}) => {
175
- let r = o("subjectId", t);
194
+ }), V = async (e, t, n = {}) => {
195
+ let r = s("subjectId", t);
176
196
  return e.query({
177
- table: y,
178
- predicate: n.status === void 0 ? r : a(r, o("status", n.status)),
197
+ table: D,
198
+ predicate: n.status === void 0 ? r : o(r, s("status", n.status)),
179
199
  order: [{
180
200
  column: "at",
181
201
  direction: "desc"
182
202
  }],
183
203
  take: n.limit ?? 100
184
204
  });
185
- }, P = (e) => {
186
- let t = E();
205
+ }, H = (e) => {
206
+ let t = P();
187
207
  return async (n) => {
188
208
  let r = n.actor !== void 0 || typeof e.query != "function" ? n : {
189
209
  ...n,
190
- actor: await d(e, n.subject.id ?? null, n.subject.type)
210
+ actor: await f(e, n.subject.id ?? null, n.subject.type)
191
211
  };
192
- await e.insert(y, t.link(A(r)));
212
+ await e.insert(D, t.link(R(r)));
193
213
  };
194
- }, F = i({ scope: "@voltro/plugin-audit" }), I = 1e3, L = [], R = () => [...L], z = () => {
195
- L.length = 0;
196
- }, B = (e) => {
197
- L.push(e), L.length > I && L.splice(0, L.length - I);
198
- }, V = (e) => {
214
+ }, U = a({ scope: "@voltro/plugin-audit" }), W = 1e3, G = [], K = () => [...G], q = () => {
215
+ G.length = 0;
216
+ }, J = (e) => {
217
+ G.push(e), G.length > W && G.splice(0, G.length - W);
218
+ }, Y = (e) => {
199
219
  let t = e.subject.id ? `${e.subject.type}:${e.subject.id}` : e.subject.type, n = e.outcome.kind === "ok" ? "ok" : "error", r = {
200
220
  subject: t,
201
221
  tenant: e.subject.tenantId ?? null,
@@ -203,8 +223,8 @@ var y = "_voltro_audit_log", b = [
203
223
  durationMs: e.outcome.durationMs,
204
224
  traceId: e.traceId
205
225
  };
206
- n === "error" ? F.warn(e.tag, r) : F.info(e.tag, r);
207
- }, H = (e, n) => typeof e == "function" ? (n) => t.suspend(() => {
226
+ n === "error" ? U.warn(e.tag, r) : U.info(e.tag, r);
227
+ }, X = (e, n) => typeof e == "function" ? (n) => t.suspend(() => {
208
228
  let r = e(n);
209
229
  return r === void 0 ? t.void : t.isEffect(r) ? r : t.tryPromise({
210
230
  try: () => Promise.resolve(r),
@@ -216,7 +236,7 @@ var y = "_voltro_audit_log", b = [
216
236
  try: () => r(e),
217
237
  catch: (e) => e
218
238
  }) : t.void;
219
- }) : e === "memory" ? (e) => t.sync(() => B(e)) : (e) => t.sync(() => V(e)), U = (e) => {
239
+ }) : e === "memory" ? (e) => t.sync(() => J(e)) : (e) => t.sync(() => Y(e)), Z = (e) => {
220
240
  let t = [], n = [], r = (e) => {
221
241
  if (e._tag !== "Empty") {
222
242
  if (e._tag === "Fail") {
@@ -240,46 +260,52 @@ var y = "_voltro_audit_log", b = [
240
260
  }
241
261
  };
242
262
  return r(e), t[0] ?? n[0] ?? e;
243
- }, W = "@voltro/plugin-audit", G = (e = {}) => {
244
- let i = e.sink === "datastore";
245
- i && u({
263
+ }, Q = "@voltro/plugin-audit", $ = (e = {}) => {
264
+ let a = e.sink === "datastore";
265
+ a && d({
246
266
  source: "plugin",
247
- table: y,
267
+ table: D,
248
268
  timeColumn: "at",
249
- ttlMs: f(process.env.VOLTRO_AUDIT_LOG_TTL_HOURS, 24 * 365)
269
+ ttlMs: p(process.env.VOLTRO_AUDIT_LOG_TTL_HOURS, 24 * 365)
250
270
  });
251
- let a, o = H(e.sink, () => a), s = (e) => e === void 0 || !e.global && !e.sticky ? e : new RegExp(e.source, e.flags.replace(/[gy]/g, "")), c = s(e.exclude), l = s(e.include), d = (e) => !(c?.test(e) || l && !l.test(e)), p = (t) => {
271
+ let o, s = X(e.sink, () => o), c = (e) => e === void 0 || !e.global && !e.sticky ? e : new RegExp(e.source, e.flags.replace(/[gy]/g, "")), l = c(e.exclude), u = c(e.include), f = (e) => !(l?.test(e) || u && !u.test(e)), m = (t) => {
252
272
  let n = e.record ?? "all";
253
273
  return n === "all" ? !0 : n === "errors" ? t.outcome.kind === "error" : n(t);
254
- }, m = { __redacted: "all" }, h = (t) => {
274
+ }, h = { __redacted: "all" }, g = (t) => {
255
275
  let n = e.redactInput ?? "all";
256
- return n === "none" ? t : {
276
+ return n === "none" ? t : n === "all" ? {
277
+ ...t,
278
+ input: h
279
+ } : n === "shape" ? {
257
280
  ...t,
258
- input: n === "all" ? m : n(t)
281
+ input: E(t.input)
282
+ } : {
283
+ ...t,
284
+ input: n(t)
259
285
  };
260
- }, g = (t) => {
286
+ }, _ = (t) => {
261
287
  let n = e.redactOutcome ?? "all";
262
288
  if (n === "none") return t;
263
- let r = n === "all" ? m : n(t), i = t.outcome;
264
- if (i.kind === "ok") return {
289
+ let r = t.outcome, i = n === "all" ? h : n === "shape" ? ((e) => E(e))(r.kind === "ok" ? r.value : r.error) : n(t);
290
+ if (r.kind === "ok") return {
265
291
  ...t,
266
292
  outcome: {
267
- ...i,
268
- value: r
293
+ ...r,
294
+ value: i
269
295
  }
270
296
  };
271
- let a = i.error, o = typeof a == "object" && a ? a._tag : void 0;
297
+ let a = r.error, o = typeof a == "object" && a ? a._tag : void 0;
272
298
  return {
273
299
  ...t,
274
300
  outcome: {
275
- ...i,
276
- error: o === void 0 ? r : {
301
+ ...r,
302
+ error: o === void 0 ? i : {
277
303
  _tag: o,
278
- ...r
304
+ ...i
279
305
  }
280
306
  }
281
307
  };
282
- }, _ = (t) => {
308
+ }, v = (t) => {
283
309
  let n = e.redactSubject ?? "metadata";
284
310
  if (n === "none") return t;
285
311
  if (typeof n == "function") return {
@@ -288,23 +314,29 @@ var y = "_voltro_audit_log", b = [
288
314
  };
289
315
  let r = t.subject;
290
316
  if (r.metadata === void 0) return t;
291
- let { metadata: i, ...a } = r;
317
+ let { metadata: i, ...a } = r, o = n === "metadata-shape" ? E(i) : h;
292
318
  return {
293
319
  ...t,
294
320
  subject: {
295
321
  ...a,
296
- metadata: m
322
+ metadata: o
297
323
  }
298
324
  };
299
- }, v = (e) => p(e) ? o(g(_(h(e)))) : t.void, b = (t) => {
325
+ }, y = (e) => {
326
+ let t = i(e.subject);
327
+ return t === null ? e : {
328
+ ...e,
329
+ impersonation: t
330
+ };
331
+ }, b = (e) => m(e) ? s(_(v(g(y(e))))) : t.void, x = (t) => {
300
332
  if (e.resolveScope !== void 0) try {
301
333
  return e.resolveScope(t);
302
334
  } catch {
303
335
  return;
304
336
  }
305
- }, x = (e, n) => d(n.tag) ? t.suspend(() => {
306
- let r = Date.now(), i = b(n);
307
- return e.pipe(t.tap((e) => v({
337
+ }, S = (e, n) => f(n.tag) ? t.suspend(() => {
338
+ let r = Date.now(), i = x(n);
339
+ return e.pipe(t.tap((e) => b({
308
340
  ts: r,
309
341
  tag: n.tag,
310
342
  subject: n.subject,
@@ -316,7 +348,7 @@ var y = "_voltro_audit_log", b = [
316
348
  value: e,
317
349
  durationMs: Date.now() - r
318
350
  }
319
- }).pipe(t.catchAllCause(() => t.void))), t.tapErrorCause((e) => v({
351
+ }).pipe(t.catchAllCause(() => t.void))), t.tapErrorCause((e) => b({
320
352
  ts: r,
321
353
  tag: n.tag,
322
354
  subject: n.subject,
@@ -325,34 +357,34 @@ var y = "_voltro_audit_log", b = [
325
357
  input: n.input,
326
358
  outcome: {
327
359
  kind: "error",
328
- error: U(e),
360
+ error: Z(e),
329
361
  durationMs: Date.now() - r
330
362
  }
331
363
  }).pipe(t.catchAllCause(() => t.void))));
332
- }) : e, S = x, C = x, w = x;
364
+ }) : e, C = S, w = S, T = S;
333
365
  return n({
334
366
  name: r({
335
- base: W,
367
+ base: Q,
336
368
  alias: e.alias
337
369
  }),
338
- baseName: W,
370
+ baseName: Q,
339
371
  description: "Records every mutation invocation; ships an audit() schema mixin for row-level metadata.",
340
372
  permissions: [
341
373
  "rpc:intercept:mutation",
342
374
  "rpc:intercept:action",
343
- ...i ? ["store:write"] : [],
375
+ ...a ? ["store:write"] : [],
344
376
  ...e.recordQueries === !0 ? ["rpc:intercept:query"] : []
345
377
  ],
346
- ...i ? {
347
- ...e.tables === !1 ? {} : { extendSchema: { tables: k } },
378
+ ...a ? {
379
+ ...e.tables === !1 ? {} : { extendSchema: { tables: L } },
348
380
  bindDataStore: (e) => {
349
- a = P(e);
381
+ o = H(e);
350
382
  }
351
383
  } : {},
352
- interceptMutation: S,
353
- interceptAction: C,
354
- ...e.recordQueries === !0 ? { interceptQuery: w } : {}
384
+ interceptMutation: C,
385
+ interceptAction: w,
386
+ ...e.recordQueries === !0 ? { interceptQuery: T } : {}
355
387
  });
356
388
  };
357
389
  //#endregion
358
- export { y as AUDIT_LOG_TABLE, e as audit, N as auditBySubject, M as auditByTrace, A as auditEventToRow, O as auditLogTable, k as auditLogTables, G as auditPlugin, T as auditRowHash, z as clearAuditBuffer, P as dataStoreAuditSink, E as makeAuditChain, R as readAuditBuffer, D as verifyAuditChain };
390
+ export { D as AUDIT_LOG_TABLE, e as audit, V as auditBySubject, B as auditByTrace, R as auditEventToRow, I as auditLogTable, L as auditLogTables, $ as auditPlugin, N as auditRowHash, q as clearAuditBuffer, H as dataStoreAuditSink, P as makeAuditChain, K as readAuditBuffer, F as verifyAuditChain };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/plugin-audit",
3
- "version": "0.36.0",
3
+ "version": "0.38.0",
4
4
  "description": "Audit plugin — ships the `audit()` schema mixin (createdAt/updatedAt/createdBy/updatedBy → Actor) plus an optional mutation interceptor that records every call to a configurable sink (console / memory / custom function).",
5
5
  "keywords": [
6
6
  "voltro",
@@ -38,9 +38,9 @@
38
38
  "node": ">=24.0.0"
39
39
  },
40
40
  "dependencies": {
41
- "@voltro/database": "0.36.0",
42
- "@voltro/logger": "0.36.0",
43
- "@voltro/protocol": "0.36.0"
41
+ "@voltro/database": "0.38.0",
42
+ "@voltro/logger": "0.38.0",
43
+ "@voltro/protocol": "0.38.0"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "effect": "^3.22.0"