A security program is a headcount problem wearing a tooling costume. The scanner is cheap; the humans who route, triage, and chase every finding are not. So the real question for any AppSec platform is not "how many findings can you surface" — it's "how much of the program can run without another hire." Our answer is to automate the work end-to-end with agents, keep that automation controlled and measurable, and spend human attention only on the handful of decisions a machine genuinely can't make.
That question got sharper this year. Attackers are compounding their own capability without hiring — cheap open-weight models let a small crew generate exploit variants, scan targets, and iterate at a scale that used to require a team. When offense scales its output without scaling its payroll, defense can't answer by scaling its payroll either. The program has to run leaner and smarter, which means the routing and bookkeeping that used to justify headcount has to move into the system. This post is about the one place in Nullify where that trade-off is most literal: an agent resolves a blocked finding inline, and the human contributes a single sentence of judgment — while retaining real, API-level points where they can reach in and override the machine.
Every AppSec tool eventually confronts the same wall. The scanner runs, the findings pile up, and then someone on the security team has to answer a question the machine can't: who owns this repo? is this GitHub username the same person as this Slack handle? is this escalated finding actually a real problem, or a false positive we can allowlist? Those questions are where a security program lives or dies, and they are exactly the ones that get dropped into a dashboard nobody opens.
We said as much internally long before we built the fix. One of our own product notes put it bluntly: "they aren't going to check the dashboard for reports every day." That is the whole problem. A finding that needs a human decision, parked behind a login on a page no one visits, is functionally the same as a finding that was never triaged. The work is blocked, and nothing tells anyone.
So we stopped modeling these as alerts. We modeled them as action items: typed, tracked units of work that a Nullify agent files when it genuinely cannot proceed on its own, and that get resolved inline — in the Slack thread, on the ticket, or in the dashboard — where the person already is. The primitive is deliberately narrow. From our internal spec:
> "An Action Item is a durable, tracked unit of work that requires a human decision or input because a Nullify AI agent cannot proceed autonomously. The existence of an action item is the blockage — there is no separate 'blocked' state."
That framing has teeth. Action items are not a place to dump every finding for human review — that would just rebuild the alert backlog under a new name. An item exists only because an agent hit a wall it can't scale past. And the ownership of the loop is inverted from how these systems usually work:
> "Core invariant: action items are created and resolved exclusively by Nullify agents. A human never creates or resolves an item through the API. The human's only role is to provide input through chat ... the chat agent interprets that input, performs the action, and resolves the item ... The REST API is therefore read-mostly plus a 'post a chat message' path — there is no human-facing create or complete endpoint."
This is the efficiency thesis stated as an architecture. The human supplies the one thing only a human can supply — a decision or a piece of knowledge — in a sentence of natural language. Everything downstream is the agent's job: interpreting "yeah that's the payments team" into a team assignment, writing the mapping, re-triaging the affected findings, marking the work done. No form to fill in, no status to set, no dashboard round-trip.
That said, "created and resolved exclusively by agents" is a statement about the default resolution path, not a claim that the system is a black box. There is deliberately no human-facing endpoint to create an item or to mark one complete — those two transitions are reserved for agents precisely because they're where a human clicking "done" would let the system lie about what happened. But humans retain real, first-class control points at the API level, and we cover exactly what they are below. The design is agents-drive-by-default with human veto and override where override is safe, not autonomy with no steering wheel.
Before this existed, the questions blocking a program were scattered across four disconnected mechanisms with no shared model. Escalated findings lived in one S3-backed struct. Clarifying questions from low-confidence triage sat in their own Postgres tables and were never even activated in production. "Pending user mapping" events were written to a log and never consumed — literally logged and forgotten, with nothing to resume the blocked work. And findings whose owning team couldn't be determined had no mechanism at all. Four different ways to be stuck, none of them tracked to closure.
We collapsed all of that into one entity with one REST surface and a small, typed schema. Today there are four kinds:
ESCALATED_FINDING — "review this finding and decide" (approve / reject / needs changes). Resolving it records the decision and, optionally, posts an idempotent PR comment.TACIT_KNOWLEDGE — a clarifying question blocking triage. Resolving it re-triages every finding that was waiting on the answer.UNMAPPED_USER — "who is GitHub#123 on Slack?" Resolving it writes the identity mapping, then re-runs every item that was blocked behind it.UNASSIGNED_TEAM — "which team owns this repo?" Resolving it assigns that team to every unassigned finding in the repo, not just the one that triggered the question.The schema is deliberately thin — a typed row, not a rich JSONB blob. The reason is telling: an AI, not a human, is filing these, and an AI "generates many phrasings of the same logical blocker." A fat, free-text struct makes deduplication harder and leaves columns sparsely populated. The shape of the data is dictated by the fact that the author is a model. That is what "AI-native" means at the schema level, not just the marketing level.
It also means a single decision fans out. Resolving one UNASSIGNED_TEAM item is one human sentence that produces N assigned findings. One answered TACIT_KNOWLEDGE question re-triages every finding that shared it. This is the point of the whole system: maximum program outcome per unit of human effort — and per unit of token spend, since a 15-minute re-triage cooldown stops retry attempts from re-billing LLM work on findings that were already processed.
The "AI generates many phrasings of the same blocker" problem isn't hypothetical — it's the thing that would drown this system in duplicate rows if nothing stopped it. The same missing team, asked across three scan runs, is one blockage; a naive create-per-mention design would file it three times, notify three times, and burn tokens re-processing it three times.
The fix is a deterministic dedup_key that is never the model's wording. For finding-anchored kinds the key is composed from the item kind plus the sorted finding IDs, so every rephrasing over the same finding set collapses to one key. For kinds with a natural anchor, the caller stamps an explicit key instead — an UNASSIGNED_TEAM item keys on unassigned_team:<repository_id>, an UNMAPPED_USER item on git_user_id:<provider_id>#<user_id>. (Link type is deliberately not part of the key: a cross-type tacit-knowledge question is one item spanning multiple finding types, so the same finding set must yield one key regardless of type.)
A partial unique index on (tenant_id, kind, dedup_key) over active rows makes that collapse a database guarantee rather than a best-effort check. The write path is a SELECT ... FOR UPDATE on the active row followed by create-or-append, and it handles the concurrent-inserter race explicitly: if two scans race to file the same blocker and one wins but has since gone terminal, the loser retries against fresh dedup state rather than either double-inserting or silently losing the append. Appending a new finding onto an existing deduped item is a first-class outcome, not an error path. The result is that "how do we avoid duplicate action items" has a concrete answer: the key is computed, not phrased, and the uniqueness is enforced in Postgres.
The easy version of this feature is checkbox theater — mark the item done, move on, hope the side effect happened. We had exactly that bug in the legacy code. An older answer_question tool returned Success: true even when all four of its re-triage calls failed, and cheerfully promised a "next scheduled cycle" that didn't exist. The item looked resolved. Nothing had happened. That single anecdote is why the resolver is built the way it is: a tool that reports success it can't verify is worse than a tool that fails, because it retires the work from everyone's attention while leaving the vulnerability live.
The action-item resolver refuses to inherit that bug. The governing rule is fail-loud resolution:
> "The resolving agent's tool transitions an item to COMPLETED only after both the item write and its side-effect succeed. If the side-effect fails, the tool returns an error to the agent ... and the item stays OPEN. There is no 'completed-with-failed-side-effect' state — every item is either OPEN (still needs action) or terminal (action verified)."
Concretely, the completion tool verifies rather than trusts. Before an UNASSIGNED_TEAM item can close, it re-checks that every linked finding actually has a team row — "team not yet assigned for N finding(s); call assign_team before completing." Before an UNMAPPED_USER item closes, it confirms the messaging mapping exists. A TACIT_KNOWLEDGE completion persists the human's answer as durable knowledge before re-triage, so the re-triage actually sees it, and guards the vacuous case where an item has zero linked findings and would otherwise close having done nothing. Any single failure leaves the item OPEN. Gated completion is the difference between "someone clicked done" and "the thing the finding needed is verifiably true now."
That gate is also where human control lives without becoming brittle. When you reply "lgtm" or "yes" or "looks good," the agent canonicalizes it to an approve decision. But a bare "no" does not map to reject — because reject is destructive (it bulk-allowlists every linked finding across services) and "must not silently suppress a real vulnerability." The safe decision gets the low-friction path; the dangerous one requires explicit intent. The human stays in the loop precisely where the loop is irreversible.
The default is that agents resolve items. But three human interventions are real, deliberate, and exposed as first-class API surface — this is the "controlled" half of the thesis, and it's worth stating precisely rather than gesturing at.
Authorization isn't left to convention. The two write endpoints — reopen and re-route — are gated on the finding:write permission, which the admin and editor roles carry and the viewer role does not, so a read-only user can watch the queue but cannot flip a dismissal or reassign a team. Both also run at tenant auth level by design: they deliberately do not take the cross-tenant read short-circuit that a NullifyDev read would, because they mutate state. That is what stops the wrong person: a viewer is blocked at the permission check, and no one outside the tenant reaches these routes at all.
POST to the reopen route flips a DISMISSED item back to OPEN and requires a non-empty reason. This is the sole human-driven terminal transition in the system: you can veto the machine's decision to drop an item, but you cannot fabricate a "done." The reason is prepended to the audit trail and the prior dismissal verdict is preserved, so reopening never destroys the record of why it was dismissed. The completion, nudge, and report clocks all reset, so the follow-up pass treats the reopened item as fresh work. There's a subtlety the endpoint handles: if a recurrence re-created an active item under the same (tenant, kind, dedup_key) while this one sat dismissed, flipping it back to OPEN would collide on the unique index, so the handler surfaces that as a conflict rather than a misleading "only DISMISSED can be reopened" error.PATCH re-assigns an item's team. Because the team column is a bare identifier with no foreign key, the handler validates the target team exists before writing it — you can change ownership, but not to a team that isn't real. What you can't do through this path is set status directly; routing is yours, resolution stays gated.So the honest description is a division of authority, not an absence of human control. Create and complete are agent-only, because those are the two transitions where a human shortcut would let the system assert something false. Reopen, re-route, and answer-in-chat are human-driven, because those are the interventions where a human's judgment should win. The REST surface is read-mostly by design, but it is not read-only.
Here is where we have to be precise, because "resolve it wherever you work" is only true for the surfaces we've actually wired. Three are live today, and they are not the same mechanism.
And one surface that is not live, which matters more than the ones that are: Teams reply-to-resolve does not work yet. Teams gets digests and posts, but you cannot resolve an action item by replying in a Teams channel today. The reason is in the code comment verbatim — Teams "carries no channel-level thread-root id today," so a lookup by the reply's own message id "is a guaranteed miss." Rather than silently no-op, the worker logs a warning and falls back to dashboard-only for Teams "until Bot Framework replyToId plumbing lands." We are calling that out here because shipping honesty is the whole brand: Teams inline resolution is planned, not present.
A system that pushes blocked work to people has an obvious failure mode: it becomes another noisy channel, and people mute it. The follow-up loop is built to make that impossible in both directions.
On the never-lose-track side, every scheduled tenant run reaps stale claims, then keyset-paginates every open item (keyset, not offset, specifically so items auto-resolved mid-scan don't cause skips) and buckets each into "needs a first nudge," "needs a re-nudge digest," or "escalate to central security." Live gauges track the open, blocked, and stalled queue depths so the backlog is measurable rather than a black box — the metrics doc comment says it plainly: without them "the queue was an operational blind spot ... no way to alarm on a tenant whose backlog is entirely blocked."
On the never-pester side, the discipline is severe. Digests are posted one message per team channel, not one per finding. Appending a new finding to an existing item never re-notifies and never re-bumps the unread badge. There is at most one re-nudge per aged item, ever, and after a small ceiling of reports to central security with no engagement, the item stops being pushed anywhere at all — it stays visible on the dashboard, but it stops interrupting people. "We never repeatedly DM or @-mention an individual."
The most important restraint is what counts as a reason to auto-close. Items auto-resolve when the underlying problem genuinely went away — an UNMAPPED_USER clears once someone mapped that identity another way; an UNASSIGNED_TEAM clears once every linked finding has any owning team. But silence is never a reason to close. A finding with no progress record is treated as still-relevant, never as a positive signal. The spec draws the line in one sentence: "Stale-relevance auto-resolve is allowed ... Silence-driven auto-resolve is not allowed." The queue clears itself when the work is actually done, and only then.
Detection was never the hard part of a security program. The hard part is the last mile — getting the handful of decisions that only a human can make made, verified, and reflected back into every finding they touch, without drowning the human in a dashboard they'll never open. When an item is resolved from any surface, we post a short note back into every thread it was ever delivered into — "this action item was resolved by a teammate via the Nullify dashboard" — so the loop visibly closes everywhere it was open.
That is the shape of program management at Nullify. An agent files a blockage only when it genuinely can't proceed, deduplicates it to a single tracked row, and drives it to a resolution whose completion is gated on the side effect actually succeeding. A human supplies the missing judgment in a sentence, and keeps real control — reopen a dismissal, re-route a team, answer in chat — at the points where human judgment should override the machine. Every step is typed, tracked, and measurable. That is how a program handles more findings each quarter without handling more headcount: the routing and bookkeeping run in the system, and the people spend their attention on the decisions that are actually theirs to make.
One honest caveat on this post: it describes the mechanism, not yet the outcome. We've deliberately stopped short of quoting resolution-time or fan-out numbers here, because the right way to make that claim is with data from the instrumentation we just described — the per-tenant queue-state gauges and the follow-up counters (items auto-resolved, re-triaged, and assigned per human sentence) already emit on every scheduled run. Those are exactly the signals that turn "one decision fans out to N findings" from an architectural promise into a measured ratio, and reporting them across live tenants is the natural next data point to publish once we have a representative window of it.