| M1-50/1 |
2026-10-13 |
Base int/m1-wave10 at 5cd89cd, branch feat/M1-50-audit-verify in its own worktree; the branch is not merged locally and the worktree stays, the PR lane ports it. Text this ticket adds is English, also in German chapters and READMEs (their German parts stay). |
Instructions of the launcher, which override the brief's merge step and the brief's language (as M1-35/1). |
| M1-50/2 |
2026-10-13 |
A break that audit.chain_verify detects ends the run without error and without a checkpoint: one more bothy.audit.verify.failures, an audit.verify entry with decision error, no actor and {first_broken_seq, failure_reason}, and the log line audit chain verification failed; River does not retry it. Storage, signing and sink errors fail the run, and River retries. See 02 section 12.3 and 19 SEC-AUD-7. |
A retry cannot repair a broken chain and would only repeat the entry; the alert BothyAuditChainBroken on the counter takes over (brief "Job" item 2). |
| M1-50/3 |
2026-10-13 |
The file sink writes <path>/<instance_id>/<seq with 20 digits>.jws with directory mode 0700 and file mode 0600 through a temporary file in the same directory (O_EXCL, synced), then a hard link to the name, then fsync of the directory; 19 SEC-AUD-7 said "rename" and now says link. <path> must exist; the sink never creates it. The same content again is accepted, other content fails and the file stays. See 19 SEC-AUD-7. |
A rename replaces an existing name (POSIX), so two writers of one seq could overwrite each other; link fails when the name exists (TestSinkConcurrentPut). A missing mount must not silently put the checkpoints on the database volume. |
| M1-50/4 |
2026-10-13 |
exportAuditEvents streams the contiguous seq span from the smallest to the largest seq whose occurred_at lies in [from, to), including a row inside the span whose occurred_at lies outside it; the description in openapi.yaml says so. An error after the first line aborts the response (http.ErrAbortHandler). |
An aggregated denial keeps the time of its first denial (05 "2.4"), so a strict time filter could drop a row from the middle and break the prev_hash link that makes the export verifiable offline (19 SEC-AUD-4); an aborted transfer is visible, a short complete-looking stream would not be. |
| M1-50/5 |
2026-10-13 |
verifyAuditChain reports the failure with the smallest seq among the row failures of the walk, the first invalid sink checkpoint (at any seq, also outside the range) and a trusted sink checkpoint whose row is gone (below the first retained row when the range starts there, above the head when it ends there; checkpoint_mismatch). from_seq and to_seq below 1 and from_seq above to_seq (also after capping at the head) are 400 validation_failed; a from_seq below the first retained row is raised to it. last_checkpoint_seq is the newest trusted sink checkpoint. |
The brief's "first failure wins" needs one order; seq is the order of the chain. Deleting the newest or oldest rows must not pass as a shorter intact chain while the sink holds a checkpoint over them. |
| M1-50/6 |
2026-10-13 |
The job walks only the rows after the last checkpoint of audit_checkpoints, after checking that checkpoint: its JWS verifies with a key of purpose audit_checkpoint, its payload names this instance and the stored seq and hash, and its row still has that hash. Without a sink checkpoints are still signed into the database and go to the sink once one is configured. |
SEC-AUD-2 recomputes "the segment since the last checkpoint"; the checks keep a changed database checkpoint from becoming the trusted start. Older rows are compared by verifyAuditChain against the sink (README "Limits of the verification"). |
| M1-50/7 |
2026-10-13 |
The insert of a checkpoint takes an advisory transaction lock and reads the last checkpoint again; when it changed since the walk, the run inserts nothing. |
Two processes with the role all could otherwise sign two checkpoints that link the same predecessor, a fork that the sink would report as checkpoint_invalid. |
| M1-50/8 |
2026-10-13 |
The route table gets Download (a 2xx response with a media type that is neither application/json nor a +json type); platform/httpserver gives such a route the deadline http.transfer_timeout for the context and the write, like an upload. Of the enabled operations only exportAuditEvents is one. |
The brief puts the export into "the route class of downloads, deadline http.transfer_timeout" (02 14.2), and the class was so far derived from the request body only. |
| M1-50/9 |
2026-10-13 |
instance.Keys.Verify(ctx, purpose, compactJWS) is added to the port; it wraps every failure about the JWS in instance.ErrJWSInvalid and leaves the check of typ to the caller (the audit module requires audit-checkpoint+json). |
The brief adds the method when the base lacks it; a database error must stay 503/500, not become checkpoint_invalid. |
| M1-50/10 |
2026-10-13 |
A stored row whose details is no JSON object or whose prev_hash or hash has another size reads with nil details or a zero hash instead of failing the read, so the walk reports hash_mismatch or link_mismatch at that row. |
Such a row can only come from a change outside the server; an error would turn a detected tampering into a 500. |
| M1-50/11 |
2026-10-13 |
Audit details of the operations: listAuditEvents names the filters that are set (actor_id, action, decision, resource_kind, resource_id, from, to), exportAuditEvents {from, to}, verifyAuditChain {from_seq, to_seq, ok}; the recorder adds ip_hmac and ip_key_id as for every entry with a client IP. The filters action and resource_kind must be codes (400 validation_failed). The entry of a read is written in a unit of work of its own before the page or the stream is returned. |
SEC-AUD-5 allows IDs, codes and times only; a filter that is no code cannot match a stored row anyway. A read whose entry cannot be written is not served. |
| M1-50/12 |
2026-10-13 |
The response validator of httpservertest checks an application/x-ndjson body line by line against the schema of the media type. |
The library reads a response body as one JSON value; every export would otherwise fail the contract check of the handler tests. |
| M1-50/13 |
2026-10-13 |
The log line of a detected break carries error.type audit_chain_broken and bothy.reason with the failure reason, not the seq. |
The attribute catalogue has no key for a sequence number (as M1-24); the audit.verify entry of the run names it. |
| M1-50/14 |
2026-10-13 |
The port types are audit.Query, audit.Page and audit.EventWriter, not the brief's AuditQuery, AuditPage and AuditEventWriter. |
The lint rule exported of revive refuses names that stutter with the package name (audit.AuditQuery); the meaning is the brief's. |
| M1-50/15 |
2026-10-13 |
Red team round 1 (NEIN), all fixed with a test each: listAuditEvents writes every item as EventJSON (canonical row plus hash, three fractional digits), TestListItemsAreCanonical; until the anchors of M7 every walk that starts at the first retained row starts at row 1, so deleted oldest rows are seq_gap at 1 (TestSECT16_DeletedOldestRowsAreDetected; this replaces the trust in the first retained row of M1-50/5); the walk reads the stored hash of a row through Rows, so a hash of another size is a detected break, not a storage error (TestWrongSizeHashIsABreak); verifyAuditChain returns its result and the job ends without retry when the audit entry cannot be written, which is reported as audit entry of the chain verification not written (TestDeletedNewestRowsStillAnswer); the to filter has a test (TestListToFilter); the use cases have tests in their own package (internal/audit/app/log_test.go), which keep the Go coverage gate above its threshold. |
A deleted newest row breaks the audit hook of M1-24 for every new entry; hiding the verification result from the owner then would hide the break itself. |
| M1-50/16 |
2026-10-13 |
With a sink, every job run first reads the sink and reports checkpoint_mismatch at a sink checkpoint newer than the last checkpoint of audit_checkpoints (lost database checkpoints); every unexported checkpoint is checked like the last one (signature, payload, row) before it goes to the sink, else checkpoint_invalid; a sink that holds other content under the seq (app.ErrCheckpointConflict) is checkpoint_mismatch. All three are breaks (no checkpoint, counter, entry, no retry). A sink that cannot be read fails the run before it signs. |
Red team round 1, minors: a deleted database checkpoint let the job fork or freeze the sink without a count, and a forged row would have been exported. |
| M1-50/17 |
2026-10-13 |
The file sink refuses a link in place of the directory of the instance or of a checkpoint file, narrows a wider mode of the directory to 0700 and syncs the root after creating the directory; ParseSink takes exactly file:// with an absolute path and refuses percent escapes and control characters. |
Red team round 1, minors (TestSinkRefusesLinksAndWideModes, TestParseSink). |
| M1-50/18 |
2026-10-13 |
Red team round 2 (NEIN), both fixed with a test each: the job reads the sink before the last checkpoint of audit_checkpoints, and the export reads the head after every batch of unexported checkpoints, so a run that overlaps the signing or the export of another run is no break (TestLog_OverlappingRunsAreNoBreak, TestLog_OverlappingExportIsNoBreak, the latter from the re-review of round 2); a row with a seq below 1 is seq_gap at that seq in the verification and the job (TestLog_RowBelowOneIsABreak). No CHECK (seq >= 1) on audit_events: the detection does not depend on it, and a role that can insert rows can append a correctly linked forged row at the head anyway. |
A checkpoint reaches the sink only after its row committed, so a database read after the sink listing sees every listed checkpoint unless it was deleted; the walk starts at row 1, so a row below 1 was never checked and must not pass as checked. |