The problem: agents are delegates, not users
Conventional auth answers "who is this user?" An AI agent is neither a user nor a service. It's a delegate. When a teacher asks an agent to propose small groups for their classroom, the agent needs to read student data on that teacher's behalf, possibly minutes or hours after the teacher stopped clicking. The naive fix, a service token that lets the Python runtime act as anyone, destroys exactly the thing FERPA demands: actor attribution. Your workflow history shows a shared service identity where it should show a specific, accountable human.
The constraint, stated precisely: no agent invocation may read student data unless the system can prove that this exact staff member is allowed to run this exact capability for this exact classroom and this exact material. And it has to be provable months later, from an append-only audit trail.
Seven designs, seven leaks
A browser request completes in 200ms with a live JWT. A Temporal workflow with a human-in-the-loop pause can sit idle for hours, then need to call back into the API. You can't rely on the session. You also can't hand the workflow a long-lived credential, because that's a standing grant of authority to a process nobody is watching.
Seven plan iterations tried variations of "put the JWT somewhere": in the workflow input, in an environment variable, in a local encrypted vault with indirection. Every one leaked. Temporal workflow history is durable, queryable, and replay-driven; anything that touches a workflow input is permanently visible to anyone with UI access. The vault wasn't durable and leaked across pod restarts. Frontend-only gating is trivially bypassable, and FERPA doesn't allow client-trusted gates.
The answer to "where do you put the credential?" was: nowhere. Invert the problem.
The permission slip
Instead of a credential, the workflow gets a grantId: a 36-character UUID with
zero PII content. Safe to log, safe to replay, safe to store forever. The authority lives in a
database row that only the API can interpret:
CapabilityExecutionGrant {
staffProfileId // who this acts as
capabilityId // what they may run
scopeKind // what shape of scope (classroom, CSV batch, ...)
scopeHash // sha256 of the canonical scope payload
status // ACTIVE → COMPLETED | EXPIRED | REVOKED
expiresAt // 15-minute TTL
}
A grant is single-use, time-boxed, and single-scope. It is a permission slip, not a password. A
partial unique index (WHERE status = 'ACTIVE') guarantees at most one live grant
per scope tuple, so concurrent mints collapse to the same row instead of multiplying authority.
Two structural details make the model hold. First, the agent endpoint takes only the
grantId and reads the classroom and material out of the grant row, so a compromised runtime cannot ask for a classroom the grant doesn't cover. That closes
the time-of-check/time-of-use gap by construction, not by review. Second, the workflow input
schemas are closed (extra='forbid'), with parametrized tests proving that fields
named token, authorization, or
supabase_access_token are rejected at the boundary. A future engineer can't
reintroduce the leak without deleting a test that says, in effect, "this was a design decision."
From schemas.py: the workflow boundary
class WorkflowStartInput(BaseModel):
"""What the FastAPI handler hands to the workflow.
Closed schema: ``extra='forbid'`` rejects JWT-shaped fields so the
handler can't accidentally smuggle a token into Temporal history.
``grant_id`` carries the server-issued grant. Activity 1 re-validates
the grant before reading the dashboard, so the classroom_id +
material_id below are advisory only — the grant's stored scope is
canonical and the agent-side endpoint reads from it.
"""
staff_profile_id: str = Field(alias="staffProfileId")
staff_role: Literal["TEACHER", "COACH", "ADMIN"] = Field(alias="staffRole")
capability_id: str = Field(alias="capabilityId")
classroom_id: str = Field(alias="classroomId")
material_id: str = Field(alias="materialId")
group_size: int = Field(alias="groupSize", ge=3, le=8)
Note what the docstring admits: the classroom and material fields the caller sends are advisory. The row is canonical. That's the TOCTOU closure, written into the schema's own documentation.
The principle: security properties should be structural, not procedural. Every hard decision in SCM converts "remember to check X" into "X is impossible." Removing a surface closes the entire class of bug, including the ones that don't exist yet.
"Maya R." is still PII
The second hard problem was what the LLM gets to see. Masking names server-side ("Maya Rodriguez" becomes "Maya R.") passes every obvious test. It's also
trivially re-identifiable by anyone who knows the roster of a 24-student classroom, and the
model provider's logging retains the prompt verbatim during the inference window.
The privacy posture got stronger by getting simpler, in four steps: mask names at the
server; validate at workflow entry that nothing unmasked slips in; scan every freeform LLM
output field for identifiers; and finally, the step that made the others obsolete: replace students with opaque per-run references. The prompt says
S1: completion=64% accuracy=0.71. The map from S1 back to a name
lives in a local variable in the activity's call frame: never serialized to workflow history,
never logged, never sent to the model. The endgame inverts the claim entirely. The agent path carries only opaque IDs and numeric metrics, and names are hydrated at the UI boundary, gated
by the human's own session. The claim stops being "we scrub names at every boundary" and
becomes "names never enter the AI region at all." That change deleted more
code than it added.
The bug that made it interesting
The first opaque-ref implementation passed every unit test and blew up in production. The strict output schema, the one that validates masked-name shape, was bound directly to the
LLM via with_structured_output. But LangChain parses and validates tool-call
arguments eagerly, inside invoke(), so the name-shape regex ran against the raw
S1 refs before rehydration ever saw the data. The fix was a loose
private mirror schema for the LLM boundary, then rehydrate, then strict validation:
loose parse → rehydrate → strict validate.
The tests had passed because the mock returned the wire shape while the real parser returned a validated runtime type. That became a permanent engineering rule in the repo: mocks of a parsing or validating library must return the same runtime type production receives, not the shape you wish the wire had.
Cost is a correctness property
Temporal's default retry policy is unlimited attempts with exponential backoff. That is correct for network calls and catastrophic for a paid, non-idempotent LLM call. The original design claimed "max 2 LLM calls per workflow," and the math was wrong: three transient retries compounding with an in-activity re-prompt meant six paid calls per click.
The fix wasn't a config value; it was a refactor. The activity was split along the LLM
boundary: a fetch activity with maximum_attempts=3 for transient infrastructure
failures, and a synthesis activity that owns the LLM call with
maximum_attempts=1, always, with the one-shot schema-violation re-prompt bounded inside that single attempt. Retry policy became semantic, per failure class: a
network blip retries; a 401 doesn't, because a 401 means the grant expired and retrying an authorization failure is conceptually wrong, not just wasteful.
From workflows.py: the split, with the cost math in the comment
context = await workflow.execute_activity(
"fetch_grant_and_context", # no LLM; transient infra may retry
workflow_input,
start_to_close_timeout=timedelta(seconds=30),
retry_policy=RetryPolicy(maximum_attempts=3),
)
# Step 2: synthesize. ``maximum_attempts=1`` caps LLM cost at one
# synthesize call per workflow execution (plus the in-activity
# re-prompt on schema violation, bounded inside the activity →
# max 2 LLM calls per workflow execution).
return await workflow.execute_activity(
"synthesize_and_validate", # owns the LLM; never retried
context,
start_to_close_timeout=timedelta(seconds=90),
retry_policy=RetryPolicy(maximum_attempts=1),
)
Human-in-the-loop breaks every fire-and-forget assumption
The first capability was blocking: the handler awaits the workflow, returns the proposal, emits its audit event on the way out. The second capability, goal-setting from an uploaded diagnostic CSV with two human approval checkpoints, broke every one of those assumptions. Each break earned its own decision record:
- The handler can't block for a pause of unbounded duration, so it starts the workflow and returns in under a second; the UI drives itself off a polling query.
- The completion audit never fires, because the handler returns before the workflow ends. The grant-retirement logic piggybacked on that audit event, so grants silently stopped retiring. Audit emission moved into workflow activities, gated behind a versioned patch marker so in-flight replays don't break.
- The grant expires during the wait, so the final write re-validates the grant immediately before committing (a second TOCTOU closure) and fails terminally on 410, because retries cannot un-expire a grant.
- Temporal is at-least-once, so a timeout after the database insert but before the acknowledgment would duplicate the batch. Idempotency comes from a key derived from the input itself: structured source columns under a partial unique index. No token table, no garbage collection, no extra round trip.
What an adversarial review taught me
I scheduled outside-voice reviews as a process rather than an accident, and one afternoon's adversarial pass found four exploitable holes. Each got fixed in its own PR. The most instructive: the workflow ID was displayed in a UI status banner and accepted as a path parameter without an ownership check. Copy another admin's banner, and you could signal their workflow. The fix wasn't validating the parameter. It was deleting the surface: route by run ID, derive the workflow ID server-side from the authenticated user. The spoofing class is gone, including variants nobody has thought of yet.
The counterintuitive lesson from the same period: defense in depth is not free, and sometimes it's negative. A duplicated role check in the Python runtime shadowed the canonical capability registry and rejected an admin the registry had just authorized. A redundant check made from a less-informed position isn't a second line of defense; it's a second source of truth. The discipline is knowing which layer owns a decision, and letting the others delegate.
Learnings
Documented trade-offs are pre-written post-mortems. One decision record explicitly accepted "no retroactive expiry" of stale grants as a trade-off. Six weeks later it caused a production incident: a grant that looked freshly minted but was actually a stale row returned by the uniqueness constraint. Because the trade-off was written down, diagnosis was immediate, and the fix (a third self-heal site, at mint) closed a known gap instead of a mystery.
The wire shape between two repos is a first-class artifact. A "stuck UI" incident traced to a Python workflow state string that had drifted from the TypeScript union rendering it. The fix was a ten-line parity doc-test that fails CI when either side changes alone. The cheapest gate that fails the build beats the elegant codegen you'll never build.
An agent should have no intrinsic authority. Everything else in SCM is downstream of one sentence, the one I'd defend in a board meeting: Claude does not have its own permissions. It always acts as a specific human and inherits exactly that human's authority — no more, no less.