The override-token system has been in production for a few weeks at this point. The math is simple — HMAC-SHA256 over a canonical serialization of the operation's kind, parameters, and a per-token nonce, with the result base64url-encoded. Verification reconstructs the canonical form from the caller's claimed params, recomputes the HMAC against the same secret, and timing-safe-compares.

The system's purpose is to let an attorney say "yes, I really do want to do this thing the guard refused, here is my deliberate authorization." Every mint is logged. Every redemption is logged. The token is single-use — once redeemed, the fingerprint is in a file that the next redemption checks against. The system is meant to be auditable in the legal-record sense: someone reviewing the trail months later should be able to see exactly which overrides were minted, which were used, and on what specific operations.

The math has been stable. The audit log has been stable. What turned out to need work was the part where the human and the tool exchange information.

Tokens that mint but never verify

The first thing we found was that aai-override task_delete — with no task_id= parameter — would walk the attorney all the way through the confirmation prompt, then mint a token whose params field was an empty object. The token verified fine in isolation against the empty-params canonical form. It just wouldn't match any real refusal site, because every guard call passes the real task ID. The mint succeeded; the use silently failed; the attorney had to start over without understanding why.

The same shape produced a second variant. aai-override task_delete task_id=1 extra=x would mint a token whose HMAC was computed over both task_id and extra. The guard's verification call only passes {task_id}. Different canonical forms, different HMACs, silent rejection.

The fix in both cases is the same: a required-params map per kind, and an extras check at mint time. Missing required params now refuses with a usage hint. Extra params now refuses with a "verifier won't see these" explanation. The mint cannot succeed unless the token would actually be useful.

This isn't a security fix. The token math was correct — these dead-on-arrival tokens couldn't bypass anything they weren't already going to refuse. It's a UX fix at the legal-record layer. A minted-and-confirmed token that doesn't work wastes the attorney's deliberate-authorization decision, and that decision is the whole point of the system.

The value vs. the routing key

The second class of problem was subtler. A recent set of releases (4.5.61 through 4.5.65) added URL normalization to the guard layer: /Tasks/del/N, /tasks/%64el/N, and any other case-or-encoded variant of the same path now all route to the same guard check, because Merus's URL routing decodes and case-folds server-side. The guard had to do the same or it could be bypassed by typing the URL slightly differently.

The normalization is one helper that takes a path segment, URL-decodes it, and lowercases the result. The guard checks segLower[0] === "tasks" and segLower[1] === "del". Both routing-key segments. Both correctly lowercased.

What we didn't notice immediately was that segLower[2] was also being read. That segment is the task ID — the value the override token's params are bound to. The mint side preserved the case of whatever the attorney typed. The verify side lowercased it. For numeric task IDs (which is all of production Merus's task IDs), that's identical: lowercasing a number is a no-op. But for any test scenario or any future endpoint with letter-case IDs, the mint produced one HMAC and the verify computed a different one. The token would be silently rejected.

The lesson here is a generalization of the routing-vs-value distinction. A guard's job is to take an HTTP path, identify which guard it is (routing), and pull out the values that parameterize the bypass. Routing should be case-insensitive and encoding-insensitive because the server is. Values should be exactly what the attorney typed, because the token's HMAC was computed over exactly what the attorney typed at mint time.

The fix introduced a second array of segments — same source, URL-decoded only, not lowercased. The routing check still uses the lowercased form; the value extraction uses the original-case form. Three test scenarios verified: mixed-case task IDs round-trip correctly, case-variant paths still hit the guard, encoded paths with numeric task IDs still hit the guard.

Operational tools

The third gap was operational. Until this cycle, the only way to rotate the HMAC secret was to manually rm ~/.aaicase/override-secret. The system would auto-regenerate on the next mint. That worked but wasn't discoverable. The help output didn't mention it. Nothing in the audit log would record that a rotation had happened. An attorney who suspected the secret had leaked had no documented procedure to follow.

Same gap on the read side. The redemption file is a JSON map of SHA-256 fingerprints to records. The records are useful — they include the kind, the params, the timestamp, and the pid of the process that redeemed the token. But reading them required cat ~/.aaicase/queue/redeemed-overrides.json | jq and matching fingerprints by hand. There was no command-line tool to print the table.

Both gaps got subcommands this cycle. aai-override --rotate-secret prompts for confirmation, unlinks the old secret, regenerates a fresh 32-byte one, and writes an audit-log entry. aai-override --list-redemptions prints a sorted table (most recent first) with timestamp, pid, kind, and params. Both are documented in the help output. The rotation is audited. The listing handles all three corruption cases (missing file, empty file, unparseable file) with distinct messages and exit codes.

The atomic download

The fourth fix was unrelated to override tokens but in the same spirit. The merus-fetch --download command wrote PDFs directly to the user-specified output path via writeFileSync. If the write failed partway (disk full, I/O error, ENOSPC), the partial bytes were left at the output path. The command would propagate the error, but the file at the path was now truncated.

Every other state-file writer in the codebase had been migrated to the write-tmp-then-rename pattern. The download path was the last holdout. Now it's not: writes go to outputPath.tmp.<pid>.<rand> first, then renameSync to the final path. On error, the tmp file gets cleaned up before the error is re-raised. The user-visible path is either complete or absent.

Why these matter for legal-record use

None of these were security bugs. The override math was sound. The HMAC was correct. Single-use enforcement was working. Path-traversal protection was working. What was wrong was that the system's behavior didn't match the attorney's mental model of what they had authorized.

A signed authorization decision is, in the legal sense, an intent recorded against a specific operation. If the attorney mints a token to delete task R4_TEST and the system silently treats that as a token for r4_test, the recorded intent doesn't match the operation that actually happened. The audit log would show "minted task_delete with task_id=R4_TEST, rejected" — but no follow-up trace explains what went wrong. The attorney has to either re-authorize (with a different value? a different invocation?) or give up. Either way, the deliberate decision they made got eaten somewhere between intent and execution.

The four fixes this cycle don't change the security envelope. They make the recorded intent and the executed operation be the same thing. For a system whose audit trail is intended to survive deposition or malpractice review, that's a property worth defending.

The version on the install URL is 4.5.71. The work continues at the pace of one verified release at a time, and the override-token system is now both more usable and more transparent than it was a week ago.