P4: Tier-2 batteries — audit + budget + persona noun #4
Reference in New Issue
Block a user
Delete Branch "phase-4-batteries"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Phase 4 (first slice): the opt-in batteries that plug into
run.Ports, each a clean lift of mort's interface/logic with the GORM impl left in mort, a zero-dependency in-memory default, and a CI-verifiedcore → batteryimport count of 0.Batteries
audit/—run.AuditSink+ per-runWriter(already matchesrun.RunRecorder) + a queryable in-processMemorystore (all 17 read/filter/purge/walk methods). End-to-end test: wireaudit.NewSink(audit.NewMemory())into the executor, run an agent, and the run is recorded with terminal status/output and queryable by caller.NewSink(nil)== no audit.budget/—DBBudget(rolling 7-day window) +NoOpsatisfyingrun.Budget(Check/Commit), with aBudgetStorageseam + in-memory rollover. Tests: per-user cap enforced, window rolls over, NoOp always allows.persona/— the Agent noun decoupled from its Discord shell:agent.go/storage.go/builtin_loader.gowith the command-binding embedding dropped (host concern).Agent.ToRunnable()lowers a persona intorun.RunnableAgent— the bridge that lets the executor run a persona without importing this battery (the inversion ofagentexec.Run(*agents.Agent)).Memorystore implements all 11 CRUD + trigger-query methods.Verification
go build/vet/test -race ./...green; corego.sumstill free of gorm/redis/discordgo/sqlite.run); core imports none of them (verified viago list -deps).Remaining P4 (separate PR)
skill noun (mirrors persona), critic / schedule / checkpoint batteries, and
contrib/store(pure-Go SQLite) — in progress now.Independent of P3 (#3) — both branch off the merged main. Gadfly's 3-cloud review is advisory; findings will be independently verified.
🤖 Generated with Claude Code
🪰 Gadfly review —
glm-5.2:cloud(ollama-cloud)Verdict: Blocking issues found — 3 reviewers: security, correctness, error-handling
🔒 Security — Blocking issues found
Confirmed:
OnTool(lines 209-225) writes"args": string(call.Arguments)and"result": truncate(result, 4000)unconditionally for every tool, whileOnStep(lines 149-155) +stepHasSecretTool(lines 171-185) only redact the assistant narration. The storage layer (memory.goAppendLog) persists the payload map verbatim, so secret-bearing tool args/results land inskill_run_logsexactly where the redaction was meant to keep secrets out. Both findings in the draft are confirmed against the actual code.Verdict: Blocking issues found
audit/writer.go:209-225—OnToolpersists raw tool arguments and results for the very tools the writer explicitly flags as secret-bearing, defeating theOnStepredaction.stepHasSecretTool(lines 171-185) redacts the assistant narration onOnStepwhen a step firesmcp_call/email_send/ anyhttp_*tool, on the stated grounds that "the audit trace never persists secret-adjacent assistant text." But the matchingOnToolcallback writes"args": string(call.Arguments)and"result": truncate(result, 4000)to the log verbatim, unconditionally, for every tool call — these are the fields that actually carry the secret (MCP args, email body/recipients, raw HTTP request/response bodies), not the narration. The secret therefore lands inskill_run_logsexactly where the redaction was meant to keep it out. The redaction is theatre: it covers the text that might echo a secret while persisting the secret itself one event later. Confirmed by readingwriter.goin full — there is no gating/redaction betweenOnToolandappendLog, andmemory.go'sAppendLogpersists the payload map as-is. Suggested fix: apply the samestepHasSecretTool-style predicate (oncall.Name) insideOnTooland redactargs/result(e.g.,"args_redacted": true) for the secret-bearing tool names, mirroring theOnSteppolicy.audit/writer.go:171-185— redaction policy is name-prefix based and brittle.strings.HasPrefix(c.Name, "http_")plus an enumeratedcase "mcp_call", "email_send"is an allowlist-of-shame approach: any new secret-bearing tool added without updating this list silently leaks. This is security-by-coincidence (it relies on every secret tool happening to be named with a known prefix). Worth at minimum a code comment pointing maintainers here when adding tools, or better, a single shared redaction-policy registry consulted by bothOnStepandOnTool. Not blocking on its own, but it amplifies the finding above.🎯 Correctness — Minor issues
VERDICT: Minor issues
The cross-package seams (
run.Audit/run.RunRecorder/run.Budget/run.RunnableAgent) line up with the kernel —Sink.StartRunmapsRunInfo→SkillRunfield-by-field,recorder.Closemapsrun.RunStats→audit.RunStats, and the budget test logic checks out (rollover at 8d, cap enforcement at 110/100). The surviving issues are divergences betweenaudit/memory.goand its own documentedStoragecontract — semantic bugs a caller relying on the doc would hit. Termination is still safe everywhere (cycles are caught by theseenmap), so none of these are blocking.audit/memory.go:209—WalkParentChainreturns the chain in the WRONG order. TheStoragecontract (audit/storage.go:202) states "oldest = root first, newest = runID last". The implementation appendsrunIDfirst then walks up viaParentRunID, producing[runID, parent, …, root](newest-first) — the exact reverse. A consumer relying on root-first ordering (the doc names "the loop guard in skill_invoke") gets the opposite. Fix: reversechainbefore returning, or build it by appending to the front.audit/memory.go:203—ListChildrenByParentreturns newest-first; contract says oldest-first.audit/storage.go:192documents "oldest first"; the impl callsnewestFirst(...)(reverse insertion order). Same divergence inListFinishedRunsBeforeatmemory.go:226vs. the "oldest first" wording atstorage.go:212.audit/memory.go:191—PurgeOlderThanpurges byFinishedAt, but the contract (storage.go:187) saysStartedAt. The impl only purges rows wherer.FinishedAt != nil && r.FinishedAt.Before(t). A run stuck instatus="running"(the doc elsewhere calls this "crash mid-run, useful signal") is never purged regardless of age, and a run started beforetbut finished aftertis retained — both contradict "deletes runs whose StartedAt is strictly before t". Align the doc and the impl (the GORM impl lives outside this checkout, so which side is canonical needs confirming against mort).audit/memory.go:102—page(...,0,limit)returns ALL matching rows forlimit<=0, not "no rows".audit/storage.go:212documents forListFinishedRunsBeforethat "limit <= 0 yields no rows (the caller is expected to specify a real bound)".pageonly truncates whenlimit > 0, so a non-positive limit returns the entire matching set. The samepagehelper carries the defect throughListRunsByCaller/ListRunsBySkill/ListRunsBySkillPaginated.audit/memory.go:172—ListRunsFiltereddocuments a limit clamp (<=0 or >500 → 50) thatmemory.godoes not implement.audit/storage.go:179promises a bounded admin scan;pageneither substitutes 50 for non-positive limits nor caps at 500, so the Memory backend returns unbounded result sets where the contract promises bounded ones. Align the doc or add the clamp.audit/storage.go:241—MaxParentChainDepth = 32is defined but unused. The interface doc (:205) says "Cap walk depth at 32… if the chain forms a cycle, we want a bounded result." The MemoryWalkParentChainrelies solely on theseenmap (which does terminate cycles) and never honors the documented depth cap. Minor — termination is still guaranteed byseen, but the documented bound is unenforced.🧯 Error handling & edge cases — Minor issues
Verdict: Minor issues
audit/memory.go:184-200(Memory.PurgeOlderThan) — theStorageinterface contract (audit/storage.go:187-189) states "deletes runs (and their logs) whose StartedAt is strictly before t". The implementation instead purges only whenr.FinishedAt != nil && r.FinishedAt.Before(t). Concrete edge impact: a run that crashed mid-execution (status stuck at"running",FinishedAt == nil) is never purged regardless of how old itsStartedAtis, so on a long-lived light-host process usingMemory, crashed rows accumulate forever — the very retention concern theMemorygodoc warns about. Either fix the predicate to match the documentedStartedAtsemantics (falling back toStartedAtwhenFinishedAt == nil), or update the interface comment to reflect that purge is keyed onFinishedAtand unfinished rows are intentionally retained. Confirmed against both files.audit/memory.go:209-224(WalkParentChain) — the interface doc (audit/storage.go:204-208) promises "Cap walk depth at 32 ... corrupted data ... bounded result rather than an infinite loop", andMaxParentChainDepthis exported as 32 (storage.go:245). The implementation guards via aseenmap with no depth cap, andgrepconfirmsMaxParentChainDepthhas no other reference in the repo (dead constant). A cyclic chain still terminates via theseenset, so there's no infinite-loop risk — but the bounded-depth contract documented on the interface is not honored. Either enforce the depth (if len(chain) >= MaxParentChainDepth { break }) or correct the doc to say theseenset is the sole guard. Confirmed.budget/budget.go:96-106(NewDBBudget) — the constructor nil-guardsnow(defaults totime.Now) but does not guardstorageorweeklyLimit.DBBudget.Check(line 128) immediately callsb.storage.Get(...)and (line 134)b.weeklyLimit(); a caller passingnilfor either gets a hard nil-pointer / nil-func panic. The constructor's own doc only declaresnownil-able ("now may be nil — defaults to time.Now"), so this is not a contract violation, but the silent panic-vs-loud-construction asymmetry withnowis worth a defensive guard or an explicit "required-non-nil" note in the doc. Real panic risk; no buggy caller exists today (only the test wires it, with valid args).audit/sink.go:39-50(Sink.StartRun) —s.storage.StartRunerror is deliberately swallowed (_ = ...). Therecorderis returned regardless, and onClosethe writer callsFinishRun, which againstMemoryreturnsErrNotFoundfor a run that never started — also swallowed viaslog.Warn(audit/writer.go:288-289). Net effect on the unhappy path: a failed start yields a silent no-op audit row plus a periodic warn-noise stream rather than a clean failure. Confirmed intentional ("Best-effort: a failed StartRun must not break the user-visible run"); noted only as a swallowed-error consequence — no change required if the design choice is conscious.Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 9m 50s
🪰 Gadfly review —
deepseek-v4-flash:cloud(ollama-cloud)Verdict: Blocking issues found · ⚠️ 1/3 lens(es) errored — 3 reviewers: security, correctness, error-handling
🔒 Security — Minor issues
I have independently verified the draft's finding against the actual code. Here is the corrected review.
🔒 Security Review — Verdict: Minor issues
Finding 1: Tool call arguments logged without redaction — potential secret leakage in audit trail
audit/writer.go:214-218— TheOnToolmethod persists the full rawcall.Arguments(ajson.RawMessage) to the audit log as the"args"field of atool_callevent:The codebase already has a
stepHasSecretToolfunction (audit/writer.go:171-185) that redacts narration text when secret-bearing tools (mcp_call,email_send,http_*) are called — but that redaction only applies to the"text"field in the"step"event. The tool call arguments themselves are logged without any equivalent filtering. This means:email_send: email body, recipients, subject are persisted in the audit loghttp_*: HTTP request bodies, headers, and URL query parameters (which may contain API keys, bearer tokens, etc.) are persistedmcp_call: MCP server arguments that could contain secrets are persistedThe audit store (
Memoryor persistent) is queryable by callers viaListRunsByCaller,ListRunsFiltered, etc. — so any user who can query the audit trail can read these arguments.Suggested fix: Apply the same secret-tool detection from
stepHasSecretToolto theOnToolmethod. For secret-bearing tools, either redact the arguments (e.g. replace with"[redacted]") or log only the tool name and ID without the arguments. Example:(Verified by reading
audit/writer.golines 209-225 and 171-185, confirmingstepHasSecretToolexists but is unused inOnTool.)🎯 Correctness — ⚠️ could not complete
⚠️ This reviewer failed to complete: agent produced no output
🧯 Error handling & edge cases — Blocking issues found
Now I have read all the relevant files. Let me verify each finding.
Finding 1 (
PurgeOlderThanchecksFinishedAtinstead ofStartedAt): The interface doc ataudit/storage.go:187-189says "whose StartedAt is strictly before t". The implementation ataudit/memory.go:191checksr.FinishedAt != nil && r.FinishedAt.Before(t). This is a clear contract violation. CONFIRMED.Finding 2 (
WalkParentChainignores the 32-depth cap): The interface doc ataudit/storage.go:205-208says "Cap walk depth at 32". The constantMaxParentChainDepth = 32exists ataudit/storage.go:245. The implementation ataudit/memory.go:209-223has only cycle detection (seenmap) but no depth counter. CONFIRMED.Finding 3 (
ListChildrenByParentreturns newest-first, not oldest-first): The interface doc ataudit/storage.go:191-199says "oldest first". The implementation ataudit/memory.go:203-206delegates tom.newestFirst(). CONFIRMED.Finding 4 (
ListFinishedRunsBeforereturns newest-first, not oldest-first): The interface doc ataudit/storage.go:211-223says "oldest first". The implementation ataudit/memory.go:226-231usesm.newestFirst(). CONFIRMED.Finding 5 (
ListRunsFiltereddoes not clamplimit): The interface doc ataudit/storage.go:179-180says "limit is clamped (<=0 or >500 → 50)". The implementation ataudit/memory.go:172-175passeslimitstraight topage()which only applies the limit whenlimit > 0 && limit < len(rs). A caller passinglimit=0gets all results. CONFIRMED.Finding 6 (
appendLogusescontext.Background()with no timeout): Ataudit/writer.go:312,appendLogcallsw.storage.AppendLog(context.Background(), log)with no deadline. This is inconsistent withClose(line 286) which usescontext.WithTimeout(context.WithoutCancel(ctx), auditFinishTimeout). CONFIRMED.Finding 7 (Silently swallowed
StartRunerror): Ataudit/sink.go:40,_ = s.storage.StartRun(...)discards the error. IfStartRunfails, the recorder is still returned, and subsequentappendLogcalls will succeed butClose→FinishRunwill fail withErrNotFound. CONFIRMED.Finding 8 (
budget/memory.go:33—Getuses fullLock()instead ofRLock()): The mutex issync.Mutex(line 13), notsync.RWMutex. TheGetmethod at line 24-33 is a read-only operation (map lookup + struct copy) but uses the exclusiveLock(). This serializes all concurrentGetcalls. CONFIRMED.All 8 findings are confirmed. The verdict remains Blocking issues found.
🧯 Error handling & edge cases — VERDICT: Blocking issues found
1.
audit/memory.go:191—PurgeOlderThanchecksFinishedAtinstead ofStartedAt(contract violation)The
Storageinterface doc (audit/storage.go:187-189) explicitly says: "PurgeOlderThan deletes runs (and their logs) whose StartedAt is strictly before t." The Memory implementation at line 191 checksr.FinishedAt != nil && r.FinishedAt.Before(t)instead. This means:"running",FinishedAt==nil) are never purged, even though theirStartedAtis well before the cutoff.FinishedAthappens to be beforet).Fix: Change line 191 to
if r.StartedAt.Before(t) {(and remove theFinishedAt != nilguard, or keep it only to skip unfinished runs if that's the intent — but the contract saysStartedAt).2.
audit/memory.go:209—WalkParentChainignores the documented 32-depth capThe
Storageinterface doc (audit/storage.go:205-208) says: "Cap walk depth at 32 to prevent pathological loops in the data itself." AMaxParentChainDepth = 32constant exists ataudit/storage.go:245but is never referenced by the Memory implementation. The Memory impl at line 209-223 only has cycle detection (seenmap) — a corrupted chain of 1000+ entries would walk all of them under the read lock, blocking all other readers.Fix: Add a depth counter and break after
MaxParentChainDepthiterations.3.
audit/memory.go:203—ListChildrenByParentreturns newest-first, not oldest-firstThe interface doc (
audit/storage.go:191-199) says "oldest first". The implementation delegates tonewestFirst()which returns reverse insertion order. Callers expecting oldest-first ordering (e.g. the call-tree view) get the wrong order.Fix: Either reverse the result before returning, or add a dedicated oldest-first helper.
4.
audit/memory.go:226—ListFinishedRunsBeforereturns newest-first, not oldest-firstSame pattern as #3. The interface doc (
audit/storage.go:211) says "oldest first", but the implementation usesnewestFirst().Fix: Reverse the result or use an oldest-first iteration.
5.
audit/memory.go:172—ListRunsFiltereddoes not clamplimitas the interface contract requiresThe interface doc (
audit/storage.go:179-180) says: "limit is clamped (<=0 or >500 → 50) to bound admin scans." The Memory implementation passeslimitstraight topage()which only applies the limit whenlimit > 0 && limit < len(rs). A caller passinglimit=0gets all results (unbounded), andlimit=1000returns up to 1000 rows. This violates the documented safety clamp.Fix: Add clamping logic in
ListRunsFiltered(or inpage()) before callingpage():6.
audit/writer.go:310—appendLogusescontext.Background()with no timeoutEvery
OnStep,OnTool,LogEvent, andLogErrorcall goes throughappendLog, which usescontext.Background()with no deadline. If the storage implementation is a remote DB that hangs, the goroutine blocks indefinitely — there is no way to cancel or time out. This is inconsistent withClose(line 286) which properly usescontext.WithTimeout(context.WithoutCancel(ctx), auditFinishTimeout).Fix: Use a context with a short timeout, e.g.:
7.
audit/sink.go:73— Silently swallowedStartRunerror creates orphaned log entriesThe
StartRuncall to storage at line 73 discards its error with_ =. IfStartRunfails (e.g. storage is temporarily unavailable), the recorder is still returned. SubsequentappendLogcalls will succeed (appending to the logs map), butClose→FinishRunwill fail withErrNotFoundbecause the run row was never created. This leaves orphaned log entries with no corresponding run row — a data integrity hole.Fix: At minimum, log the
StartRunfailure so operators can detect the data loss. Alternatively, returnnilfromStartRunwhen the storage write fails (skipping audit entirely for that run, which is cleaner than partial data).8.
budget/memory.go:33—Getuses fullLock()instead ofRLock()The
Getmethod is a read-only operation (map lookup + struct copy) but the mutex issync.Mutex(notsync.RWMutex), so it acquires the exclusive write lock. This serializes all concurrentGetcalls even though they could safely run in parallel. Not a correctness bug, but needlessly limits throughput under concurrent budget checks.Fix: Change
mu sync.Mutextomu sync.RWMutex, then usem.mu.RLock()/m.mu.RUnlock()inGet.Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 20m 0s
🪰 Gadfly review —
minimax-m3:cloud(ollama-cloud)Verdict: Blocking issues found — 3 reviewers: security, correctness, error-handling
🔒 Security — Blocking issues found
VERDICT: Blocking issues found
🔒 Security findings
audit/writer.go:209-224(OnTool) — Tool-arg secret leak. Confirmed.OnStepchecksstepHasSecretTool(resp)and redactions the assistanttext(text_redacted: true) when a secret-bearing tool fired (mcp_call,email_send,http_*— seestepHasSecretToolataudit/writer.go:171-185). ButOnToolataudit/writer.go:214-224persistsargs: string(call.Arguments)andresult: truncate(result, 4000)verbatim for every tool call, including those same tools. An MCP token, email recipient/body, or HTTP auth header passed ascall.Argumentslands in plaintext inskill_run_logs.payload.args, defeating the policyOnStepsets up. Fix: gateargs/resulton the samestepHasSecretTool-style check (or pass the resp/tool-call list context through) and emitargs_redacted: true+ length hint for secret-bearing tools, mirroring the existingtext_redactedbehavior.audit/sink.go:40-50— Inputs persisted unredacted. Confirmed.info.Inputsfromrun.RunInfois copied straight into theSkillRun.Inputscolumn (audit/storage.go:29— no comment about redaction; field typemap[string]any). ThestepHasSecretToolguard inOnStepis per-event, not row-level; nothing redacts the input snapshot, so a user prompt containing secrets/PII persists verbatim.audit/writer.go:303-315(appendLog) — Unboundedcontext.Background()on hot path. Confirmed. EveryOnStep/OnTool/LogEvent/LogErrorcall ultimately invokesw.storage.AppendLog(context.Background(), …)with no timeout. OnlyCloseusesauditFinishTimeout(10s,audit/writer.go:297). A hung audit DB blocks the run goroutine on every step. Fix: routeappendLogthrough a short-timeout context (auditAppendTimeout, e.g. 1s) for defence-in-depth parity withClose.audit/sink.go:40-50—StartRunerror silently dropped, recorder still returned. Confirmed._ = s.storage.StartRun(…)ataudit/sink.go:40swallows the error;appendLog(audit/writer.go:303-315) does not verify the run exists before writingAppendLogevents. A failedStartRunproduces a silent audit gap with no surfaced signal beyond a per-event slog.Warn.persona/builtin_loader.go:466,562—WebhookIPAllowlistnot CIDR-validated. Confirmed. The YAML tagwebhook_ip_allowlist(builtin_loader.go:466) flows straight intoAgent.WebhookIPAllowlist []string(builtin_loader.go:562);grepconfirms nonet.ParseCIDR/net.ParseIPcall anywhere inpersona/. The struct comment atpersona/agent.go:111documents "CIDR strings" but the loader accepts arbitrary strings, so malformed entries propagate silently. Fix: validate each entry withnet.ParseCIDRat decode time indecodeAgentManifest, slog.Warn per bad entry, skip it (matching the existingslog.Warnpattern for missing skills / extends errors atbuiltin_loader.go:135,153,160,168,189,194).🔒 Clean (verified)
WebhookSecretdeliberately not seeded from YAML; preserved across reloads viafresh.WebhookSecret = existing.WebhookSecret(persona/builtin_loader.go:411, verified). Comment explicitly calls out the credential-leak risk.WebhookSignatureRequiredcarried forward viafresh.WebhookSignatureRequired = existing.WebhookSignatureRequired(persona/builtin_loader.go:412, verified).dec.KnownFields(true)indecodeAgentManifest(persona/builtin_loader.go:511, verified).audit.NewSink(nil)returns nil recorder;StartRunearly-returns nil whens.storage == nil(audit/sink.go:31-33, verified); covered byTestNilSinkRecordsNothing(audit/audit_test.go:71-78).🎯 Correctness — Minor issues
VERDICT: Minor issues
Findings (Correctness lens):
audit/memory.go:188 —
kept := m.order[:0:0]is benign but stylistically off. The[:0:0]zero-cap form forces a fresh allocation each call rather than reusing the existing slice's capacity (m.order[:0]). Not a correctness bug; flagged only as a deliberate-looking pattern that may not be intentional.audit/storage.go:187-189 vs audit/memory.go:191 —
PurgeOlderThandoc/impl mismatch. The Storage interface comment says "deletes runs (and their logs) whose StartedAt is strictly before t," but theMemoryimplementation keys onr.FinishedAt.Before(t). Callers passingt = time.Now()expecting "drop old starts" will silently keep running-but-stuck rows. Suggested fix: align the doc toFinishedAt.Before(t)(the actual semantics, which intentionally retain stuck rows) — or change the impl to key onStartedAtif that semantics is wanted.budget/memory.go:47 vs budget/budget.go:133 — boundary asymmetry on the rolling 7-day window.
Memory.Addrolls over atnow.Sub(WindowStart) >= 7*24*time.Hour(inclusive), whileDBBudget.Checkenforces at< 7*24*time.Hour(exclusive). The two predicates disagree by exactly one nanosecond. Not a real-world bug, but the asymmetry is real and is worth aligning (both to inclusive, or both to exclusive).persona/runnable.go:10-37 —
ToRunnablesilently drops a large slice ofAgentfields.EncryptionEnabled,ExecutionLane,PersonalizationSources,Schedule,NextRunAt,LastScheduledRunAt,WebhookSecret,WebhookSignatureRequired,WebhookIPAllowlist,ChatbotChannelFilter,DefaultEmoji,StateReactEmoji,Tags, plusPhases[].FallbackMessageandPhases[].IsRunFuncare all lost in the DTO. The doc says "static shape only" so this is by design, but a future phase wiring any of these viaRunnableAgentwill silently get nothing. Worth a doc note naming the supported lowering set.persona/memory.go:85-98 —
GetAgentByWebhookSecretlinear-scans every agent underm.mu.RLock. O(N) per incoming webhook. With thousands of personas with webhook secrets enabled, every webhook triggers a full scan holding the RLock. Not a correctness issue today, but a contention hazard that scales poorly and would be cleaner as amap[string]stringkeyed onWebhookSecret.audit/writer.go:267-271 —
Closecomment names mort callers that are not in this PR. The comment says "the caller (skillexec.runInner / agentexec.runInner) ALSO detaches at the call site" — those identifiers are mort package paths, not executus code. The actual in-PR caller (run/executor.go:288viafinishAudit) does detach via the package's owndetach()helper (run/executor.go:314), so the defence-in-depth behaviour is real; but the comment reads as dead-history for this battery. Substantively harmless, just misleading documentation.go.mod —
gopkg.in/yaml.v3 v3.0.1added at top-level. Consumed bypersona/builtin_loader.go. The PR framing claims core/batteries stay stdlib-only;yaml.v3is the first non-stdlib third-party dep in a new package. Leaf decoder, low risk — but it does deviate from the stated "stdlib-only core" framing, so worth confirming the dependency budget is OK with this addition.Nothing blocking found.
run/package confirmed to import none ofaudit,budget, orpersona(no matches in grep). The end-to-end audit test wires throughrun.Ports.Auditcorrectly (Sink.StartRun withSubjectID = ra.IDverified).ToRunnablemapping round-trips the fields the test checks.DBBudgetrolling-window test passes againstMemory.Addsemantics.🧯 Error handling & edge cases — Blocking issues found
🧯 Error handling & edge cases review
VERDICT: Blocking issues found
Findings
audit/memory.go:144-170(matchesFilter) contradicts the documentedRunFiltercontract. The doc onaudit/storage.go:104-106says ofRunFilter.IncludeDryRun: "Ignored when Status is set explicitly (an explicit Status=='dry_run' still matches)." But the implementation evaluates the dry-run gate before the Status check and unconditionally excludesstatus="dry_run"wheneverIncludeDryRun=false, even when the caller explicitly setStatus="dry_run". Net effect: a host asking for exactly the dry-run rows (Status:"dry_run", IncludeDryRun:false) gets an empty page. Fix: whenf.Status != "", skip the dry-run pre-filter. Verified againstaudit/storage.go:104-106andaudit/memory.go:145-148.audit/memory.go:247(LastRunBySkills) does not match its documented semantics. Doc onaudit/storage.go:232-234states: "When false, only status='ok' rows count." The implementation only excludeserrorandtimeout; rows withstatus="budget_exceeded","preempted","lane_busy", or"cancelled"still pass through and surface as the "last successful run" timestamp on dashboards. Fix: change the predicate to!includeFailed && r.Status != "ok", or explicitly enumerate every non-ok status you want to drop. Verified againstaudit/storage.go:230-238andaudit/memory.go:247.audit/writer.go:312(appendLog) silently drops events on storage failure. EveryOnStep/OnTool/LogEvent/LogErrorultimately callsAppendLogwithcontext.Background(). IfAppendLogconsistently errors (broken DB connection), the failure is only surfaced as aslog.Warnwith no upper bound on how many warns get emitted. Under a sustained outage a single run can flood the log with thousands of warnings. Acceptable as "audit must not break user-visible execution" but worth a rate-limit / circuit-break (or at least debounce to the first + last error of a run) so a misconfigured DB doesn't drownmort.log. Edge-case concern.budget/budget.go:1stale package doc. The header comment says// Package skillexec runs saved Skill definitions via majordomo's agent loop— that's mort's skillexec package, notbudget. Cosmetic but signals an untidy lift; fix the doc to describe thebudgetpackage.budget/budget.go:133duplicates the window constant.Checkuses the literal7*24*time.HourwhileMemory.Adduses the namedbudgetWindowconstant declared inbudget/storage.go:32(same package — sobudget.gocould reference it directly). Same value, but the duplicate literal is exactly the kind of magic number that drifts on future edits. Fix: usebudgetWindowinCheck.budget/budget.go:38NewNoOpBudgetreturns the internalBudgetTracker, notrun.Budget. The constructor signature isfunc NewNoOpBudget() BudgetTracker { return NoOpBudget{} }. A caller who writesvar b run.Budget = budget.NewNoOpBudget()will get a type-mismatch —run.Budget(run/ports.go:95-100) andBudgetTracker(budget/budget.go:24-30) have identical methods but are distinct interface types. The intent stated inbudget/run.go:6-8is that these plug intorun.Ports.Budget, so the public constructor should return something assignable torun.Budget.Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 7m 35s
Pull request closed