ADR 0038: Koda's key reaches the admin API through the role guard, for the sections the owner named, creating and reviewing but never deleting or publishing¶
Status: Accepted 2026-11-01 by the owner (the instruction that set the sections and the "no deletion" rule); the route list below is the session's reading of that instruction, for the owner to confirm or trim on the Koda tab Decision date: 2026-11-01 Owners: Workload owner and architecture owner
Related: issue #1477; ADR 0037 (the Agent Content API this amends); the Agent Content API runbook, part 7.
Context¶
ADR 0037 gave Koda (the owner's content agent, run by Muse) one hashed key and a
small API of its own under /api/agent-content/v1/: the Drafts stage and the
published reads, behind five switches. Its guard was deliberately disjoint from
the admin role guard: the key passed nothing that called requireRole, so no
/api/cms/* route and no workflow function could ever see it.
On 2026-11-01 the owner widened Koda's job: it will be creating content, and it should have access to the Pipeline section (New Content, Drafts, Review Queue, Editor, Live Pages), the Enhanced section (Listen & Learn, Labs, Frameworks, Coder Corner, Blogs), the Second Brain alone in the Creative section, the Spotlight section (Speaking, Certifications, Ambassador, Doc Vault) and, in the Platform section, Health and Integrations only. It may create, produce and review; it may not delete.
Those sections are served by about a hundred routes, each guarded by the admin
role guard with a required role, most of them editor. Copying each behind the
key guard, as ADR 0037 did for the Drafts routes, would have meant a second
registration and a second test for every one, drifting from the first the day
either changed.
Purpose and decision drivers¶
- Give Koda every route it needs to create and review content in the named sections, and nothing in the sections the owner did not name.
- Keep one implementation per route: the handler the admin uses is the handler Koda uses, with the same validation, limits and audit rows.
- Keep the lines ADR 0037 drew where the owner kept them: no deleting, no publishing, scheduling or unpublishing, no settings, keys or secrets.
- Keep the owner able to narrow Koda's reach without a deploy, as the five switches already do.
- Keep every refusal explainable: a row in
admin_audit_logswith the reason, and content-free: the route as the policy spells it (the template, orunlisted), never the concrete path, whose segments can be document ids.
Decision¶
- The admin role guard accepts the key, on an allow-list.
getDefaultGuard()now returns the role guard wrapped bywithAgentAccess(functions/src/lib/agent-content/admin-access.js). A request whose selected credential is shaped like a site key takes the key path: the key guard's ownauthenticate(hash compare, revocation, the 120-a-minute limit,lastUsedAt), then the policy table. Any other request takes the role guard's path unchanged.requireUserrefuses a key outright. - The policy is a table of method and route, each under a section switch.
POLICYlists about a hundred rows, every one a route this Function App registers (admin-access-registry.test.jsholds each row to a registration and its methods). Five new switches on the Koda tab,admin.pipeline,admin.enhanced,admin.brain,admin.spotlightandadmin.platform, open their rows; a switch that is off refuses with 403 and an audit row, like a capability. The upload route is under Pipeline and Spotlight both, because articles and certifications both carry images, but the row carries asegmentsmap so the container asked for decides which switch opens it:content,blogsandcoversare Pipeline's,certificationsandspeakereventsare Spotlight's, and any other container is refused as an unlisted route (Copilot review, 2026-11-01). - DELETE is never allowed. No row has it, and
matchPolicyrefuses the verb before looking. The POST routes that delete or trash (deleteContentItem,deleteRejectedContent,deleteSpeakerEvent,softDeleteLivePage, the image deletes) are not listed. The v1 API'sDELETE drafts/{id}route and itsdrafts.deleteswitch are removed, so there is no delete the owner could turn on by mistake (Copilot review, 2026-11-01). - Publishing stays a human act.
publishContent,saveContentSchedule,unscheduleContent,unpublishContentToInspected, the slug and rehost writes and the podcast publish are not listed, and neither are the three routes that unpublish as a side effect with nothing in their body to stop it:requestContentInspectionandresetContentReviewStatewrite a published item back toingested, and the Listen & Learn review route withdraws an episode todraft(Copilot review, 2026-11-01). Routes that could reach publishing through their body are allowed with a body rule, read from a clone of the request so the handler still gets its body: atransitionContentStatusmay move content toapproved,editing,forge_readyorrejectedand never topublishedorarchived; anenqueueJobmay enqueueforge-article,forge-from-url,batch-inspect,sync-blog-sourceorgenerate-listen-and-learnand neverpublish-content; acreateContentItemmay not ask, underdatawhere the handler reads the item, to be bornpublished(or one of the olderpublished_blog,published_news,published_bothspellings, which are stored as given and read as published) orarchivedorLive; a book or chapter edit in Listen & Learn may restore (archived: false) but not archive, since archiving takes it off the site. A refused field that arrives as anything but a plain value (an array, an object) is refused outright.updateContentItemneeds no rule: it already refuses the workflow fields (contentStatus,Status,Live). - The role each row grants is the least that opens it.
editornearly everywhere;viewerfor the dashboard, queue, publish-board, health and lab reads;publisheronly for the Doc Vault, whose every route, reads included, requires it (lib/doc-vault/model.js, the owner's request of 2026-10-26). A handler that asks for more than the row grants is refused with 403 and reasonrole. - Koda acts as
koda (agent)everywhere but the Second Brain. The brain is the owner's private workspace, partitioned by the owner's object id and guarded byrequireBrainOwner, so on its routes the key acts as the owner (asOwner: the owner'soidandtidfromBRAIN_OWNER_OIDandBRAIN_OWNER_TENANT_ID, withactingFor: 'owner',preferred_username: 'koda (agent)'andagent: 'koda'kept). Notes Koda writes land in the owner's partition; the audit rows still name the key. The sync routes, the desktop plugin's, andreindexare not listed. - Not listed, by the owner's section list: Forge Studio, AI Engine, Image
Prompts (including its keyword configuration) and the Image Gallery's
writes (Creative, other than the brain), the generic config read
(
cms/config/{collection}, whose only collections are the AI providers and the MCP servers), every Amplify route (calendar, newsletters, mailing list, social, Linkie, recordings), Platform Settings, secrets, credentials, admins, bootstrap, MCP, AI routing and model catalogue, the lab agent registry, the Coder token, lab job launches and cancels, and the integration-status and probe-results writes. Gallery reads are allowed under Pipeline so an article can pick an image. - Every refusal of a presented key is a row, including the key guard's
own (wrong, revoked, over the limit), which the wrapper tells the surface
and the route template before it checks the key, and the user-only
routes (the sign-in expectations, the current-admin status, the
bootstrap), which refuse a key with 401 and a
user-only-routerow. - Discovery and the tab.
GET /api/agent-content/v1/statusand the key route both carryadminAccess: each section, its switch state and its routes with their body limits. The Koda tab shows it as "What Koda may do in the admin".
Consequences and accepted risks¶
- Koda's writes through the admin routes are attributed as the admin routes
attribute any editor:
actor()resolves tokoda (agent),userIdtoagent:koda,userEmailto null. In the Second Brain the editor id is the owner's, by design; the denial rows andrecordAdminAuditrows still carry the agent name. - The key is as powerful as an editor on the listed routes. Its limit stays 120 calls a minute, its revocation immediate, and every row it may call is on the tab. The owner narrows it by switch; widening needs a pull request.
- A body rule reads the request twice (clone and handler). The bodies are small JSON; the cost is a parse.
- ADR 0037's sentence "the key passes nothing that calls
requireRole" no longer holds; its amendment points here. The route-inventory test still knows the key guard by name for the v1 routes, and the admin routes still count as guarded through the role guard they always called.
Alternatives considered¶
- Mirror every route under
/api/agent-content/v1/, as ADR 0037 did for Drafts. Rejected: about a hundred registrations and handler compositions to keep in step with the originals, for no gain in safety, since the policy table can say the same thing in one place. - Give the key an admin record and let it be an editor. Rejected: an editor
can delete and an editor's token reaches every editor route; the owner asked
for less than that, and a record would also let the key through
requireUser. - Let Koda publish. Rejected, as in ADR 0037: publishing is the owner's act in the site's own screens. The owner can reverse this with a row in the table.
Validation and revisit triggers¶
admin-access.test.js: the table lists no DELETE and no publishing, deleting, settings, secrets or admin route; matching, the body rules and theasOwneridentity; the wrapper's refusals and their audit rows.admin-access-registry.test.js: every row names a registered route and a method it answers; the publishing, deleting, secrets and admin registrations are not listed for a write.- Revisit when a section is added to the admin, when a route in a listed section gains a destructive verb or body, or when the owner wants Koda to publish or to reach a section not named here.
Related decisions and references¶
- ADR 0037: the key, the v1 API and the lines kept.
- The Doc Vault's publisher-only rule (
functions/src/lib/doc-vault/model.js), which is why its rows grantpublisher. - Agent Content API runbook, part 7: the route list as a table, and how to narrow it.