back to the three assignments

assignment 3 · security questionnaire automation

Trust Answer Engine

answer once, never again

screen recording · the pipeline running end to end, live in Slack

The assignment

Security and compliance demands from enterprise buyers funnel to one human. Build a system that answers automatically, triggered from Slack.

What I shipped: A live system, not just a demo: a Slack app connected to a real workspace, a versioned trust knowledge base, and buyer-ready exports, tested end to end against real Slack events.

Deliverable
Live Slack app + video
Knowledge base
61 concepts · 10 categories
Grounding
OKF v0.2, cited, tiered, dated
01

When the missing document became the problem

I kept returning to one line from the brief: the trust site has a lot of useful information, but may not necessarily be enough. Sometimes the answer means talking to someone or finding a document that is not on the trust site. That was the moment the no-document question became the real problem for me.

I mapped the review around enterprise buyers in the Canva, Notion, Samsung class. They will not accept certificates alone; their internal policy requires a completed questionnaire on file, so every enterprise deal produces a security review. At the ceiling, a questionnaire has 800 questions. Those questions repeat heavily across buyers, then funnel to one person who answers them manually with an AI chatbot in the loop.

What stopped me was the question with no documented answer. Today that means walking to an engineer, getting a sentence, pasting it into a spreadsheet, and then losing it when the next buyer asks the same thing. I wanted the design to make that answer durable instead of letting the knowledge evaporate as soon as it was used.

Answer once, never again.

the rule I wanted the system to keep

I did not see “add an LLM” as the missing piece. A chatbot is already in the loop and the problem remains. The useful system needs grounding, citation, confidence gating, routing to the right human, and capture of what that human said.

02

What happened to the 800 questions

  1. 800

    Questions in

    one buyer workbook

  2. 84

    Distinct concepts

    after catalogue matching

  3. 478

    Auto-answered

    cited, gated, verified

  4. 23

    Drafted

    needs a human read

  5. 299

    Escalated

    routed to an owner

  6. 48

    Gaps recorded

    ranked commercial input

Sequence of 6 pegged cards.

the offline run, one workbook in, six outcomes out

On my offline run, 800 questions became 84 distinct concepts because buyers ask the same things in different words. I keyed the answer bank on concept, never on the question string. That lets one governed answer carry across buyers instead of making the work start over with every workbook.

I kept 299 in view as a useful result, not an embarrassing one. Roughly a third of the workbook goes to a human, and the system treats that as the correct outcome when the evidence is not strong enough to answer on its own.

Unanswerable is a first-class outcome, and a valuable one , it’s a product input.

the decision I made about uncertainty
03

Why I chose selection over authorship

I made selection the core safety property because a model-authored sentence could quietly become a buyer-facing security claim. The model never writes that text. The wording lives in a knowledge-base file as a canonical ## Answer body. An agent’s job is to select which concepts a question maps onto, and a second read-only pass verifies the claims against their cited sources. Merging and rendering are deterministic code, so the final wording is not improvised at answer time.

never invent a claimread-only toolsdeterministic render

I keep the selecting agents bounded to Read, Grep and Glob. They have no write, shell, or web access, and they work within bounded turns and a bounded budget. Their structured output is checked against the real catalogue paths, so a hallucinated concept path fails validation instead of producing a confident wrong answer.

Optimise for never being wrong, not for throughput.

the trade I made deliberately

I can recover from over-escalation, even when it is annoying. A confidently wrong security answer is a misrepresentation to a customer, in writing, with the company’s logo on it.

04

I treated the knowledge base as the product

I built the answer bank as a versioned bundle of 61 concept files across ten categories, access control, AI governance, compliance, data handling, deployment, encryption, governance, personnel, resilience, tenancy. A concept’s file path is its identity. Each file carries frontmatter and three body sections: the canonical answer, its scope and limits, and internal notes that never go to a buyer.

FieldWhat it does
posturefull · partial · none · not_applicable , drives how the cell renders
sourcesEach with an id, tier, disclosure setting and citation label
verifiedWho verified it. A human entry outranks a machine entry.
statusdraft or stable. Only stable can auto-answer.
stale_afterA date. Past it, the concept drops to draft.
also_asked_asBuyer phrasings, so the same concept is found by different words
framework_refsSOC 2, ISO 27001:2022 and CAIQ mappings , vocabulary, not evidence
ownerWho answers questions about this fact
commercialAsk frequency and blocked revenue, for the internal view only

The evidence tiers I used, and the rule behind them

1

Auditor attested

SOC 2 report, ISO certificates, VAPT report

citable
2

Governed internal

Policies, control statements, network diagrams

citable
3

Company-published technical

Security and privacy whitepapers, completed CAIQ

citable
4

Marketing

Website copy, guides , may seed phrasing, never a citation

not citable
5

Inferred from code

Draft only. Never auto-answerable.

not citable
how I treat tier 4: it can find the concept, never the citation

I keep buyer citations at the artifact level: a direct link, or “available under NDA via the trust centre,” or “available on request.” Page and section numbers are stripped. Internal citations keep the source id, the concept path, the exact claim text and the tier. That detail belongs in my audit trail, not in a buyer’s spreadsheet.

I use freshness as a gate, not a badge. When a concept’s stale_after date has passed, it stops auto-answering and becomes a draft, even if everything else about it is perfect. Stale cited evidence must wait for a human to re-verify it before it can answer for me.

05

How I turn a question into an answer

  1. Slack: file or /trust ask

    a workbook, or one question

  2. Normalise the workbook

    merged cells · answer types · row provenance

  3. 800 rows

    Match to the catalogue

    concept-first, not question-to-question

  4. 84 concepts

    Select concepts

    read-only agent · paths, not prose

  5. then check

    Verify the claims

    separate read-only pass

  6. entailment counts

    Compose deterministically

    worst posture · earliest staleness

  7. posture + trust

    Trust gate

    answer · draft · escalate

  8. persist

    Answer artifact + audit event

    written before the status changes

  9. after review

    XLSX round-trip + PDF

    sanitised · disposition-gated

one row, the path I implemented from workbook cell to buyer-ready deliverable

I enforce the honesty at composition time. When a question maps onto several concepts, the merge takes the worst posture, the highest deal-risk severity and the earliest staleness date across all of them. No model writes connective prose between the canonical bodies. I concatenate them in match-strength order, then append scope, limits and compensating controls when the posture requires them.

I let posture decide the cell, and code decide posture

PostureBinary cellFree textComment
fullYesCanonical answerOptional scope bound
partialNo , never YesAnswer + scope/limits + compensating controlsRequired
noneNoStates the absence, plus roadmap positionRequired
not_applicableN/AWhy it does not applyRequired
I never let a partial posture render as Yes, that is a code path, not a judgement call

The five checks I require before anything answers a buyer by itself

  • I require human verification of the concept; machine-only verification is not enough.
  • The concept status must be stable, not draft.
  • It must be fresh, with its stale_after date strictly in the future.
  • Every claim must be entailed by its cited sources, with a non-zero claim count.
  • Its support must be tier 1–3. Tier 4 or 5 can support escalation only.

If any one of those checks fails while governed support exists, I return a draft for a human to read. If there is no governed support at all, I escalate. I store no numeric trust score. Trust is derived at read time from the records, so it cannot drift out of sync with them.

06

How I keep the sources from contradicting one another

Before I let any row be answered, I sweep the whole knowledge base for conflicts. Real trust surfaces disagree with themselves, and whichever version lands in a buyer’s spreadsheet can become the official answer. I wanted that problem visible before it reaches the workbook.

The sweep extracts quantities, counts, durations, retention and deletion windows, RTO/RPO-shaped values, and compares sources that measure the same thing. It also compares assertions lexically by subject, flags geographic conflicts such as US versus EU residency, shared versus dedicated infrastructure claims, and drift between governed material and marketing copy.

Compatible inconsistency

I can let “Over 25” and “50+” both be true. The governed source ships, and a source-integrity alert goes to the document owner.

Tier drift

When marketing says one thing and a governed document says another, I use precedence: the governed source ships. Marketing is corrected, never cited.

Blocking conflict

A strict, material conflict that the tiers cannot resolve is the only kind I block, and I block only the rows whose selected concepts actually intersect the finding.

When I do have to resolve a conflict automatically, I use a conservative rule, always: longer durations, smaller capacities and counts. I never resolve toward the value that helps close the deal.

07

The answer that needs a person

Once I reached the no-document case, the system had to do something useful with it. When no document answers the question, I do not guess or silently drop the row. I pick an owner, ask in Slack, and turn the reply into a durable asset.

  1. No trustworthy match

    or the gate said escalate

  2. recorded

    Escalation record on disk

    durable, not an in-memory queue

  3. assign

    Owner suggested, or named

    send q1 q3 to @arnav, q2 to @anshal

  4. posted to Slack

    Slack card: human input needed

    question + assignee + Answer button

  5. Answer

    Structured modal

    answer · confidence · shareable? · confirmer

  6. no agent involved

    Captured as a concept file

    partial posture · unverified · 90-day horizon

  7. next tick

    Becomes a reviewable draft

    cannot render as a positive Yes

  8. second verifier

    Promoted by a second human

    into the permanent answer bank

when no document answers, I route to a human and keep the result

The modal asks for more than prose: how certain the person is (certain, fairly sure, or needs someone else), whether the answer can be shared with a customer at all, and optionally who else should confirm it. Capture writes the file and appends an audit event without calling an agent. I store the human’s words verbatim, not paraphrased.

A captured answer starts as a draft with an empty verification list, a partial posture and a ninety-day review horizon. It is not born stale, and it cannot render as a confident Yes. Promotion into the permanent bank requires a different second human, valid frontmatter, a freshness horizon, a catalogue row and a log entry. I do not let one person both write and bless a security claim.

Every human answer is captured as a durable concept.

what I use capture for

I made escalations state, not suspended processes

I keep everything waiting on disk as state in a manifest. A reconciler walks those files on a loop, derives what is actionable now, and dispatches it. Nothing is held in memory waiting for a callback, so the queue is a derived value.

Restart-safe

I write and fsync the answer artifact before changing the status. If a crash lands between those writes, the next pass repairs it without redoing the work.

Waiting is a state

Assigned, awaiting-human and drafted are not actionable by the agent layer. After 24 hours I nudge a waiting row; after 72 I reassign it upward.

Failure is bounded

I use exponential backoff and three attempts, then make the row terminally needs-a-human, keeping its error and using the ordinary escalation path.

Idempotent

An existing valid answer artifact is my recovery key. Running the loop again changes nothing, which makes it safe to run on a timer.
08

I give the buyer back the same spreadsheet

I do not send buyers to a dashboard when they need their file filled in. The exporter reopens the original workbook and writes answers back using the row-and-sheet provenance captured during parsing, preserving structure, row order and merged cells. Text is protected from being interpreted as an Excel formula. A PDF is produced alongside it, with a cover page of currently disclosable certifications.

I put two guards in front of that export. Every row must carry one of five explicit dispositions, answered, answered with caveat, declined, not applicable, or deferred to a call, or the export refuses and names the offending rows. Both exporters run through the same sanitiser, which strips Slack URLs, employee identifiers, local file paths and ticket ids, and drops every citation that is not public, NDA-gated or available on request.

What I send the buyer

Their workbook, filled in place. A PDF with questions, answers, safe artifact-level citations and framework mappings. Nothing about internal risk, owners, or blocked revenue.

What I keep inside

Posture gaps with severity, blocked revenue, the owner, the remediation path, and how many times it has been asked. Two outputs from one row, deliberately never mixed.
09

What the unanswered rows tell me commercially

I wanted an unanswered question to leave useful evidence about the product. A concept with a partial or absent posture becomes a standing gap-register entry, ordered by blocked revenue, then by how often buyers ask, then by path.

SignalWeight added
Posture is partialstarts at 1
Posture is nonestarts at 3
Buyer marked the question mandatory+2
Any blocked revenue attached+1
Blocked revenue ≥ $100k+1
Blocked revenue ≥ $500k+1
Asked 3+ times in 90 days+1
Asked 10+ times in 90 days+1
Remediation not started+1
score ≥ 3 is high risk · the top label, blocker, is only ever returned with explicit human confirmation

My offline run produces 48 distinct questionnaire gaps and an eleven-entry standing register. I can use that register in a roadmap conversation: a recurring security question with revenue attached stops being a support burden and becomes a prioritised input. It is the same loop the second assignment is about, seen from the demand side.

buyer text

bounded, cited, honest about limits

internal record

gap, severity, revenue, owner, ask count

the two views I keep for one row
10

Then I actually turned it on in Slack

Everything above was true of the offline version. Then I connected it to a real Slack workspace, Socket Mode, an installed bot, a live channel, and asked it real questions. That is a different kind of testing than a fixture file, and it found problems the offline suite never would have.

  • The Answer button on an escalation card did nothing. Slack puts button clicks inside body.actions[0], not at the top level of the payload the way I had assumed, so the handler was silently returning early on every click.
  • AES-256 kept coming back as [redacted]. My ticket-id redaction regex matched any short run of capital letters and digits, which is also the shape of a standard cipher name, so I had to teach it the difference.
  • A bulk upload of nine questions was fully answered, but every single row landed as a draft instead of auto-approving, even though the same questions had auto-approved cleanly in another run minutes earlier.

That last one is the one I actually care about, because it is not a UI bug, it is the gate lying to me. I trace it below rather than just listing it, because the trace is the point.

Tracing the bug that mattered: a false zero, not a real one

My auto-approve rule requires every atomic claim in an answer to come back entailed, verified === total, and total has to be greater than zero. A verifier that says nothing at all, 0/0, can never pass that check on purpose, because I never want “I checked nothing” to look the same as “I checked it and it’s fine.”

When I pulled the actual answer artifacts for both runs side by side, the buyer text, the citations and the resolved concept paths were identical. The only difference was that in the drafted run, the Claude Agent SDK verifier call had come back with an empty concept-path list for those rows, and my merge code trusted that empty list over the concept paths I already knew were resolved. Six correct answers got zeroed out because the model dropped a field on that particular pass, not because the evidence was actually thin.

When the verifier drops the paths it was asked to check but still says it verified them, fall back to what I already resolved instead of trusting a blank field.

what I changed

I fixed the merge so a row with real resolved concepts but an empty echoed list re-derives its total from the concepts I know it actually checked, while a genuinely partial verification, a real 1 out of 2, stays exactly as reported. Then I went back into the run that had drafted six answers, confirmed each one against the correctly-verified version, and approved them the same way a human reviewer would from the dashboard, through the Approve action, not by editing a file directly.

The features I only found the shape of by using them

FeatureWhat using it live changed
Answer from either surfaceThe Answer button works from Slack and from the dashboard, same governed capture path, so a reviewer is not stuck choosing one tool
Gated file intakeA bare file upload is now ignored on purpose. Ingestion only fires for a file attached to a /trust ask message, or authorised by a bare /trust ask for the next 2 minutes
One line back, not nineWhen a bulk upload finishes fully approved, Slack gets one line and the completed workbook, not one message per answered row
Runs, not run IDsThe dashboard board reads like Slack upload / question text instead of a bare UUID, once I actually had a dashboard full of real runs to look at

None of that shows up in a spec. It shows up when you send the bot a real questionnaire from a real channel and watch what actually happens to your screen.

11

What I am still not claiming

The live run gave me real confidence in the wiring. It did not give me an accuracy benchmark, and I want to be exact about the difference.

  • My offline verification confirms a selected concept has a body and at least one source. My online verification is a live entailment check by the Claude Agent SDK, and I now know it can drop its own output on a given pass, which is exactly why I added the fallback above.
  • I have no accuracy evaluation set. A 26-question hand audit came out around 90% top-1 precision at roughly 63% row coverage; that is a hand count, not a measured benchmark.
  • Owners, Linear issue ids, account names and blocked-revenue figures are illustrative placeholders. The questionnaire is synthetic, structured on public CAIQ v4, SIG Lite and VSA patterns.
  • I simulate dispositions in the offline run so the export gate can be demonstrated end to end. That is recorded as an audit event and is explicitly not human review.

Answers are selected, not generated.

the mechanism in one line
the other two assignmentsA3 · synthetic questionnaire · public sources