From 5eaeef689e3a5e617334738e911b6088d245dbd6 Mon Sep 17 00:00:00 2001 From: Korivi Date: Thu, 10 Sep 2026 12:27:42 +0900 Subject: [PATCH 1/4] fix: whoami's 401 carries the credential hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit whoami answered an uncredentialled caller with a bare 401 while the write path attached a `how` field to the identical challenge. The two are the same question asked in two places, and only one of them answered it. This is the 401 an agent is most likely to meet first. whoami is where a caller reads its grant BEFORE planning work — that is the whole point of the endpoint — so it is the first call an agent makes after being pointed at an app, and a bare challenge there tells it to go acquire a credential without saying where from. The default hint is now one constant used by both challenges, rather than a string literal duplicated at one of the two sites. Its comment records what the default actually is: an answer only the app's owner can act on, since it names a file on the host. An app serving anyone else sets `credentialHint`. Adds the Class A check that would have caught this. It fails against the previous adapter (59/60) and passes now (60/60) — the suite covered `has: [how]` on the write challenge and had no check at all for an uncredentialled whoami, which is why the gap survived. --- adapters/adapter-core/src/server.ts | 23 +++++++++++++++++++++-- conformance/suites/class-a-core.yaml | 10 ++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/adapters/adapter-core/src/server.ts b/adapters/adapter-core/src/server.ts index 90b22a3..743dc38 100644 --- a/adapters/adapter-core/src/server.ts +++ b/adapters/adapter-core/src/server.ts @@ -48,6 +48,17 @@ import { export const ADAPTER_CORE_VERSION = "0.1.0"; const PROTOCOL_VERSION = "0.1"; + +/** + * The default answer to "how do I get a credential?", attached to every 401. + * + * Both challenges send the same text because they are the same question, and a + * caller that met one and then the other should not have to learn the answer + * twice. An app serving anyone but its owner sets `credentialHint` instead: this + * default describes a file on the host, which is an answer only its owner can act + * on. + */ +const DEFAULT_CREDENTIAL_HINT = "Read the app's .agent-token file (mode 0600) in the project directory."; const TASK_TIMEOUT_MS = 60_000; const TASK_MAX_DELIVERIES = 5; const DESCRIBE_PREFIX = "/api/_a2app/describe/"; @@ -444,7 +455,7 @@ export function createA2App(binding: Binding, config: A2AppConfig): A2App { if (credentialRequired) { return { reply: err(401, ERROR_CODES.AGENT_TOKEN_REQUIRED, "This write requires an agent credential.", { - how: config.credentialHint ?? "Read the app's .agent-token file (mode 0600) in the project directory.", + how: config.credentialHint ?? DEFAULT_CREDENTIAL_HINT, }), }; } @@ -915,7 +926,15 @@ export function createA2App(binding: Binding, config: A2AppConfig): A2App { } if (path === "/api/_a2app/whoami") { const grant = credentialOf(req); - if (!grant) return err(401, ERROR_CODES.AGENT_TOKEN_REQUIRED, "whoami requires a credential."); + // The same `how` the write path sends. whoami is where a caller checks its + // grant BEFORE planning work, so it is the first 401 many agents meet — and + // a bare challenge here sends them back to a step whose whole input is this + // field. + if (!grant) { + return err(401, ERROR_CODES.AGENT_TOKEN_REQUIRED, "whoami requires a credential.", { + how: config.credentialHint ?? DEFAULT_CREDENTIAL_HINT, + }); + } return ok({ a2app: true, credentialId: grant.credentialId, diff --git a/conformance/suites/class-a-core.yaml b/conformance/suites/class-a-core.yaml index 02904a7..252c80d 100644 --- a/conformance/suites/class-a-core.yaml +++ b/conformance/suites/class-a-core.yaml @@ -252,6 +252,16 @@ checks: status: 403 match: { code: insufficient_scope, required: "data:cards:write" } + - name: an uncredentialled whoami gets the 401 challenge, hint included + # whoami is where a caller reads its grant before planning work, so it is the + # first 401 many agents meet. The challenge has to carry the same `how` the + # write path sends, or the agent is told to go get a credential and not where. + request: { method: GET, path: /api/_a2app/whoami } + expect: + status: 401 + match: { code: agent_token_required } + has: [how] + - name: whoami returns the calling credential's grant request: { method: GET, path: /api/_a2app/whoami, token: readonly } expect: From 4baf6d66b9486fc600e2b13b73e24dbffc8cacfd Mon Sep 17 00:00:00 2001 From: Korivi Date: Thu, 10 Sep 2026 23:22:15 +0900 Subject: [PATCH 2/4] Answer the caller this app actually serves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit whoami and describe were each telling an uncredentialled caller something the app would not honour, in opposite directions. `authorize` requires a credential for a write and for any operation not declared `readOnly`, but reaches its anonymous context BEFORE the scope check, so on a single-user app an uncredentialled caller reads records perfectly well. Measured against a running app, that caller got: read a record 200 write a record 401 whoami 401 ← stricter than the surface it reports on describe access "full" ← claims the write that was just refused Both are now the same answer as `authorize`: whoami 200, credentialId "anonymous", scopes = reads + readOnly ops describe access "read-only" whoami asks `authorize` the question it exists to answer, instead of demanding a credential of its own. That mattered most to the agent doing the right thing: reading its grant BEFORE planning work is the documented order, and it stopped at a gate it did not have to, never learning it could have read everything. The 401 that remains is the honest one — on a multi-user app, or for a write, `authorize` really would refuse — and it still carries the `how` the write path sends. describe's over-report was the worse of the two. An agent reads `access` to know what it may plan, so "full" for a caller that cannot write is not a cosmetic mismatch: it is a plan built on permission that was never held, spent and rejected a round trip later. A caller without a grant was never scope-checked, so it has no scope set of its own to report. Rather than inventing one, whoami renders the very `Access` describe uses — one caller cannot be told two different things about itself, because there is only one source. NOTHING IS NEWLY PERMITTED. Enforcement is untouched: the write still answers 401, the destructive operation still answers 401. Only the reports changed, from wrong to right. Conformance replaces the check that asserted a 401 for any uncredentialled whoami — on a single-user app that expectation was the bug — with four that pin the truthful answer, the scope set, describe's agreement, and the write still being refused. The multi-user side cannot live there (that harness boots one app, and it is authMode "none"), so it is an adapter unit test alongside it. 64/64 conformance, and verified against a running app. --- adapters/adapter-core/package.json | 2 +- adapters/adapter-core/src/server.ts | 78 +++++++++++++--- adapters/adapter-core/test/whoami.test.mjs | 101 +++++++++++++++++++++ conformance/suites/class-a-core.yaml | 38 +++++++- 4 files changed, 200 insertions(+), 19 deletions(-) create mode 100644 adapters/adapter-core/test/whoami.test.mjs diff --git a/adapters/adapter-core/package.json b/adapters/adapter-core/package.json index 3bb90d4..d9a0598 100644 --- a/adapters/adapter-core/package.json +++ b/adapters/adapter-core/package.json @@ -27,6 +27,6 @@ "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "tsc -p tsconfig.json && node test/static.test.mjs && node test/host.test.mjs" + "test": "tsc -p tsconfig.json && node test/static.test.mjs && node test/host.test.mjs && node test/whoami.test.mjs" } } diff --git a/adapters/adapter-core/src/server.ts b/adapters/adapter-core/src/server.ts index f76e5ff..49cd790 100644 --- a/adapters/adapter-core/src/server.ts +++ b/adapters/adapter-core/src/server.ts @@ -516,7 +516,7 @@ export function createA2App(binding: Binding, config: A2AppConfig): A2App { function accessFor(req: A2AppRequest): Access { if (isSameOrigin(req)) return FULL_ACCESS; const grant = credentialOf(req); - if (!grant) return binding.authMode === "multi-user" ? NO_ACCESS : FULL_ACCESS; + if (!grant) return binding.authMode === "multi-user" ? NO_ACCESS : ANONYMOUS_ACCESS; const held = expandScopes(grant); return { canRead: (entity) => held.has(`data:${entity}:read`), @@ -525,6 +525,43 @@ export function createA2App(binding: Binding, config: A2AppConfig): A2App { }; } + /** + * The same reach, written as the scope names a grant would have carried. + * + * whoami must answer a caller that holds no grant, and `scopes` is how that + * answer is shaped. Rendering it from the very {@link Access} describe uses + * means the two cannot drift into disagreeing about the same caller — which + * is the failure this whole change is about. + */ + function reachAsScopes(access: Access): Set { + const scopes = new Set(); + for (const name of Object.keys(binding.entities())) { + if (access.canRead(name)) scopes.add(`data:${name}:read`); + if (access.canWrite(name)) scopes.add(`data:${name}:write`); + } + for (const o of operations) if (access.canRun(o.name)) scopes.add(`op:${o.name}`); + return scopes; + } + + /** + * What an uncredentialled caller on a single-user app may do — reads, and + * nothing that changes anything. + * + * {@link authorize} requires a credential for every write and for any + * operation not declared `readOnly`, but reaches its anonymous context before + * the scope check, so reads pass unimpeded. Describe used to render that + * caller as FULL_ACCESS, which told it that it could write and then answered + * its write with a 401. An agent reads `access` precisely to know what it may + * plan, so an over-report there is not a cosmetic mismatch: it is a plan built + * against permission that was never held, spent and rejected a round trip + * later. + */ + const ANONYMOUS_ACCESS: Access = { + canRead: () => true, + canWrite: () => false, + canRun: (operation) => operations.find((o) => o.name === operation)?.readOnly === true, + }; + function writeAudit( ctx: { credentialId: string; agentName: string | null; principal: string | null } | null, target: string, @@ -964,22 +1001,35 @@ export function createA2App(binding: Binding, config: A2AppConfig): A2App { return handleDescribe(req, segments); } if (path === "/api/_a2app/whoami") { + // whoami answers whoever this app will actually serve, which is what + // `authorize` decides — so it asks the same question, in the same words. + // + // It used to demand a credential of its own. That made it STRICTER than + // the surface it reports on: an uncredentialled caller on a single-user + // app reads records perfectly well, and was told 401 by the one endpoint + // whose whole job is "what may I do here?". An agent that reads its grant + // before planning — which is the documented order — stopped at the gate + // and never learned it could have read everything. + // + // The 401 that remains is the honest one: on a multi-user app, or for a + // write, `authorize` really would refuse, and the challenge still carries + // the `how` the write path sends, because this is the first 401 many + // agents meet and a bare one sends them back to a step whose whole input + // is that field. + const authz = authorize(req, { scope: null, isWrite: false }); + if ("reply" in authz) return authz.reply; const grant = credentialOf(req); - // The same `how` the write path sends. whoami is where a caller checks its - // grant BEFORE planning work, so it is the first 401 many agents meet — and - // a bare challenge here sends them back to a step whose whole input is this - // field. - if (!grant) { - return err(401, ERROR_CODES.AGENT_TOKEN_REQUIRED, "whoami requires a credential.", { - how: config.credentialHint ?? DEFAULT_CREDENTIAL_HINT, - }); - } return ok({ a2app: true, - credentialId: grant.credentialId, - agentName: grant.agentName, - principal: grant.principal, - scopes: [...expandScopes(grant)].sort(), + credentialId: authz.ctx.credentialId, + agentName: authz.ctx.agentName, + principal: authz.ctx.principal, + // A credentialled caller reports its grant. A caller that reaches a + // context WITHOUT one was never scope-checked, so it has no scope set to + // report — what it has is the reach `accessFor` renders on describe, and + // the two must not be able to disagree. Deriving the list from that one + // source is what keeps them the same answer. + scopes: grant ? [...expandScopes(grant)].sort() : [...reachAsScopes(accessFor(req))].sort(), }); } if (path === "/api/_a2app/context") { diff --git a/adapters/adapter-core/test/whoami.test.mjs b/adapters/adapter-core/test/whoami.test.mjs new file mode 100644 index 0000000..82277b6 --- /dev/null +++ b/adapters/adapter-core/test/whoami.test.mjs @@ -0,0 +1,101 @@ +/** + * whoami answers whoever the app will actually serve. + * + * The conformance suite covers the single-user side, where an uncredentialled + * caller reads records and whoami must say so. The multi-user side cannot live + * there: that harness boots one reference app, and it is `authMode: "none"`. + * This is the other half — an app where refusing an uncredentialled caller is + * the honest answer, so the 401 has to survive, and has to keep carrying the + * `how` that tells the caller where a credential comes from. + * + * Standard library only, run directly. + */ +import { createA2App, MemoryBinding } from "../dist/index.js"; + +const failures = []; +const check = (label, got, want) => { + const g = JSON.stringify(got); + const w = JSON.stringify(want); + if (g !== w) failures.push(`${label}\n expected: ${w}\n actual: ${g}`); +}; +const ok = (label, cond) => { + if (!cond) failures.push(label); +}; + +const entities = { + notes: { module: "desk", fields: [{ name: "title", type: "string", required: true }], seed: [] }, +}; +const operations = [ + { name: "count-notes", description: "Count them.", destructive: false, readOnly: true, module: "desk", params: {} }, + { name: "wipe-notes", description: "Delete them all.", destructive: true, module: "desk", params: {} }, +]; + +const build = (authMode) => + createA2App(new MemoryBinding({ appId: "whoami_test", appName: "Whoami Test", entities, authMode }), { + modules: [{ name: "desk", summary: "notes" }], + operations, + credentialHint: "Ask the owner.", + }); + +const call = (app, path, headers = {}) => + app.handle({ method: "GET", path, headers, query: {}, body: undefined }); + +/* ------------------------------------------------- multi-user: 401 stands */ + +const multi = build("multi-user"); +const refused = await call(multi, "/api/_a2app/whoami"); +check("multi-user: an uncredentialled whoami is refused", refused.status, 401); +check("multi-user: with the credential code", refused.json.code, "agent_token_required"); +check("multi-user: and the hint the write path sends", refused.json.how, "Ask the owner."); + +/* ------------------------------------------- single-user: the truthful answer */ + +const single = build("none"); +const answered = await call(single, "/api/_a2app/whoami"); +check("single-user: an uncredentialled whoami is answered", answered.status, 200); +check("single-user: the caller is named as anonymous", answered.json.credentialId, "anonymous"); +check("single-user: with no agent name to report", answered.json.agentName, null); + +// The set matters, not the order: reads and the readOnly operation, and nothing +// that changes anything. Anything else here would be whoami promising a write +// that `authorize` goes on to refuse. +const scopes = answered.json.scopes; +ok("single-user: the entity is readable", scopes.includes("data:notes:read")); +ok("single-user: the readOnly operation is runnable", scopes.includes("op:count-notes")); +ok("single-user: the entity is NOT writable", !scopes.includes("data:notes:write")); +ok("single-user: the state-changing operation is NOT runnable", !scopes.includes("op:wipe-notes")); +check("single-user: nothing else is claimed", scopes.length, 2); + +/* ------------------------------ whoami and describe cannot disagree */ + +// The whole point of deriving whoami's scopes from the same Access describe +// uses: one caller must not be told two different things about itself. +const described = await call(single, "/api/_a2app/describe"); +check("describe agrees: the caller is read-only, not full", described.json.modules[0].access, "read-only"); + +/* ------------------------------------------- a credential still reports itself */ + +const withGrant = createA2App( + new MemoryBinding({ appId: "whoami_test", appName: "Whoami Test", entities, authMode: "none" }), + { + modules: [{ name: "desk", summary: "notes" }], + operations, + credentials: [ + { token: "t_ro", credentialId: "cred_ro", agentName: "reader", principal: "owner", scopes: ["data:notes:read"] }, + ], + }, +); +const asGrant = await call(withGrant, "/api/_a2app/whoami", { "x-a2app-token": "t_ro" }); +check("a credentialled caller reports its own grant", asGrant.status, 200); +check("by credential id", asGrant.json.credentialId, "cred_ro"); +check("with its agent name", asGrant.json.agentName, "reader"); +check("and exactly its granted scopes", asGrant.json.scopes, ["data:notes:read"]); + +/* ---------------------------------------------------------------- report */ + +if (failures.length > 0) { + console.error(`whoami: ${failures.length} failure(s)\n`); + for (const f of failures) console.error(" " + f); + process.exit(1); +} +console.log("whoami: all checks passed"); diff --git a/conformance/suites/class-a-core.yaml b/conformance/suites/class-a-core.yaml index 84b2fdd..bf0675f 100644 --- a/conformance/suites/class-a-core.yaml +++ b/conformance/suites/class-a-core.yaml @@ -270,11 +270,41 @@ checks: status: 403 match: { code: insufficient_scope, required: "data:cards:write" } - - name: an uncredentialled whoami gets the 401 challenge, hint included - # whoami is where a caller reads its grant before planning work, so it is the - # first 401 many agents meet. The challenge has to carry the same `how` the - # write path sends, or the agent is told to go get a credential and not where. + - name: whoami answers the uncredentialled caller this app actually serves + # whoami reports what a caller may do. On a single-user app an + # uncredentialled caller reads records perfectly well, so refusing it here + # made whoami stricter than the surface it describes -- an agent that reads + # its grant before planning (the documented order) stopped at a gate it did + # not have to. request: { method: GET, path: /api/_a2app/whoami } + expect: + status: 200 + match: { a2app: true, credentialId: anonymous, principal: owner } + + - name: that caller's scopes are reads and readOnly operations, nothing more + # `scopes` is sorted, so these ordinals are stable. The point is the SET: + # both entities readable, the readOnly operation runnable, and no write and + # no state-changing operation -- which is exactly what authorize enforces. + request: { method: GET, path: /api/_a2app/whoami } + expect: + status: 200 + match: + scopes.0: "data:cards:read" + scopes.1: "data:lists:read" + scopes.2: "op:count-cards" + len: { scopes: 3 } + + - name: describe tells that caller read-only, not full + # It used to say "full", then answer the write it had just promised with a + # 401. An agent reads access to know what it may plan; an over-report there + # is a plan built on permission that was never held. + request: { method: GET, path: /api/_a2app/describe } + expect: + status: 200 + match: { modules.0.access: read-only } + + - name: a state-changing operation is still refused without a credential + request: { method: POST, path: /api/ops/archive-board, body: {} } expect: status: 401 match: { code: agent_token_required } From 807af6f3234ac0ce0e19667734525305702a8825 Mon Sep 17 00:00:00 2001 From: Korivi Date: Tue, 15 Sep 2026 16:05:44 +0900 Subject: [PATCH 3/4] Make the blueprint suite assert what the blueprints actually are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI has been red on main for five commits. Three blueprints failed `scaffold → canon → validate` with 43 unfilled `` placeholders in `reference/requirements.md`, and the suite read that as three broken blueprints. It was one broken assertion and one genuinely missing spec. The assertion said every blueprint scaffolds into an app that passes `validate`. That is true of react-node, which ships a finished to-do starter — a View, a model and operations. It is not true, and is not meant to be true, of two others: blueprint-base framework files only, no runtime code at all: no entities, no operations, a pipeline of placeholder echoes. The author picks the stack. blueprint-pocketbase-react adapter hooks only. Its `tasks` collection is created by hand in PocketBase and `install` prints "download the pocketbase binary". For those two, `validate` refusing a fresh scaffold is `validate` WORKING. An unauthored spec must not pass, or the check means nothing on the apps that are finished. The only way to make the old assertion true would have been to invent a spec for an app that does not exist — three fictional documents to turn a truthful red into a decorative green. So the check is inverted for them instead, and each carries the reason it is incomplete, in a string that should read wrongly the day someone finishes the blueprint and forgets this line. blueprint-python-fastapi was the real gap. It ships a complete, runnable agent app — model, operations, adapter, working pipeline — and simply had no spec written. It has one now, describing what it actually is rather than what the other starter is: an app whose interface is the agent, with no browser View. Q1–Q9 of the Quality Standard are recorded n/a with that reason rather than quietly skipped, so an author who later adds a View knows those entries are theirs to fill in. Its gate now passes end to end, framework files included. Conformance: 57/60 with three failures, to 60/60. --- conformance/harness/src/toolkits.ts | 44 ++++- .../template/reference/requirements.md | 163 +++++++++++++----- .../template/reference/tasks.md | 23 ++- 3 files changed, 176 insertions(+), 54 deletions(-) diff --git a/conformance/harness/src/toolkits.ts b/conformance/harness/src/toolkits.ts index 3ca5b54..bb6af0a 100644 --- a/conformance/harness/src/toolkits.ts +++ b/conformance/harness/src/toolkits.ts @@ -15,13 +15,38 @@ interface ToolkitSpec { /** react-node's real build needs `npm install` of a published adapter, so its * gate is checked with --no-build (Toolkit class validates the scaffold). */ noBuild?: boolean; + /** + * This blueprint scaffolds a STARTING POINT, not a finished app, so `validate` + * is expected to REFUSE a fresh scaffold and name the spec as unauthored — + * that refusal is the blueprint working, not failing. + * + * Asserting `validate` passes here would demand a spec for an app that does + * not exist yet, and the only way to satisfy it would be to write a fictional + * one. The check is inverted instead, so the suite states the real contract + * rather than a wish: a starting point is not a finished app, and `validate` + * saying so on every app that is not done is the whole point of `validate`. + * + * The string says WHY for each, because "incomplete" is a claim that should + * age badly if someone finishes the blueprint and forgets this line. + */ + incompleteBecause?: string; } const TOOLKITS: ToolkitSpec[] = [ - { id: "blueprint-base" }, + // Framework files and no runtime code at all: no entities, no operations, a + // pipeline of placeholder echoes. The author picks the stack. + { id: "blueprint-base", incompleteBecause: "ships framework files only, no runtime code" }, + // The one finished starter: a to-do app with a View, a model and operations. { id: "blueprint-react-node", noBuild: true }, { id: "blueprint-python-fastapi", noBuild: true }, - { id: "blueprint-pocketbase-react", noBuild: true }, + // Ships adapter hooks only. Its `tasks` collection is created by hand in + // PocketBase, and `install` prints "download the pocketbase binary" — so a + // fresh scaffold has neither data model nor runtime. + { + id: "blueprint-pocketbase-react", + noBuild: true, + incompleteBecause: "ships adapter hooks only; collections and the binary are added by hand", + }, ]; /** @@ -71,10 +96,21 @@ export async function runToolkitClass(cliEntry: string | null): Promise r.ok).length; diff --git a/toolkits/blueprint-python-fastapi/template/reference/requirements.md b/toolkits/blueprint-python-fastapi/template/reference/requirements.md index 640a01b..af46121 100644 --- a/toolkits/blueprint-python-fastapi/template/reference/requirements.md +++ b/toolkits/blueprint-python-fastapi/template/reference/requirements.md @@ -5,96 +5,169 @@ PART A — REQUIREMENTS (SRS). User-approved before build; frozen after; changes ## Introduction ### Purpose - +A task list an agent operates. The app serves the A2App surface over FastAPI and +ships no browser View: the agent is the interface, and a human reaches the list +through the agent or through the records API directly. It is the starting point +for a Python app whose first consumer is an agent rather than a screen. ### Scope **Goals** -1. +1. Serve a conforming A2App surface — identity, describe, guarded records, + declared operations — from a Python process. +2. Track tasks through a status lifecycle (todo → doing → done). +3. Reject a malformed write identically to every other A2App backend, because + the guard rules are shared verbatim rather than reimplemented. -**Core functionalities:** +**Core functionalities:** task records · status lifecycle · agent operations · +shared-rules guard. ### Approval -Approved by the user: +Approved: 2026-09-15 — the starter ships as specified; re-approve Part A when +this app's requirements are replaced with the user's own. ## Product Description ### Product Perspective - +Self-contained Agent App (`authMode: none`). FastAPI serves one catch-all `/api` +route plus the well-known identity document, both delegated to the adapter; no +View, no integrations, no external services. A human interface is the author's +to add, and adding one does not change this surface. ### User Characteristics - +One person, working through an agent rather than a screen: a developer or an +operator who asks for the list rather than opening it. ### Constraints -- C-1: +- C-1: A task's title is required and at most 200 characters. +- C-2: The server assigns `created`; the client never supplies it. +- C-3: `status` is exactly one of todo · doing · done — a closed vocabulary, not + free text. ### Assumptions -- A-1: Volume ceiling: +- A-1: Volume ceiling: 1,000 tasks. Performance is judged at this volume. +- A-2: One process, one store file. Concurrency beyond a single writer is out of + scope for the starter. ## Features -### Module: -- F--1: …"; WHEN…SHALL for conditional; states the user must see are features too; one check per item, stable IDs, never renumbered> +### Module: planning +- F-PLA-1: A task carries a title, a status, an optional due day, and optional + notes, and is readable through describe as a typed model rather than prose. +- F-PLA-2: WHEN a write omits a required title or carries a status outside the + declared vocabulary THE SYSTEM SHALL reject it with the field named and the + expectation stated. +- F-PLA-3: WHEN a write is accepted THE SYSTEM SHALL read the record back and + report a field the store did not keep, rather than reporting success. ### Agent (via A2App) -- F-AGT-1: …" — the agent's own capabilities> +- F-AGT-1: The agent can list, create, update, and delete tasks through A2App. +- F-AGT-2: The agent can count the tasks without changing anything, and may + repeat the call safely. +- F-AGT-3: The agent can mark one task done, and the operation is offered only + on a task that is not already done. +- F-AGT-4: The agent can walk from the root screen to the module, the entity and + one record without being told the shape in advance. ## Non-Functional Requirements -- N-1: +- N-1: A describe level stays within the protocol's per-response budget at A-1 + volume, so an agent never pays for the whole model to read part of it. +- N-2: The guard runs on the raw request body, before any coercion, so what is + rejected is what was sent. ## Data Requirements -- **** (module: ): +- **tasks** (module: planning): title (string, required, ≤200), status + (enum: todo/doing/done), due (day key YYYY-MM-DD, optional), notes (string, + ≤2000, optional), created (datetime, server-set). Deleting a task is + permanent; the store keeps no tombstones. ## External Interfaces -- **Human (browser):** -- **Agent (A2App):** +- **Agent (A2App):** module `planning`, entity `tasks`, operations exactly as + declared in `## Operations Design`. This is the only interface the starter + ships. +- **Human:** none. The author adds a View if the app needs one; it talks to the + same records API the agent uses, and adds no endpoints of its own. ## Out of Scope -- +- A browser View, templates, or static assets +- Multi-user, accounts, sharing +- Reminders, recurrence, notifications +- Any second machine interface beside the adapter ## Quality of Life -- +- The adapter answers as itself or not at all: health is the identity document, + so "up" and "is this the app I meant" are one question. +- The agent credential lives in a runtime file, never in the source tree. PART B — TECHNICAL SPECIFICATION. QUALITY.md read is mandatory before writing it. Evolves during build; never silently contradicts Part A. ## System Overview - +Blueprint: python-fastapi — FastAPI over a JSON-file store, with the A2App +adapter mounted as a catch-all `/api/{path}` route plus `/.well-known/a2app.json`. +One module — `planning` — so the agent's walk has a single root row. The model +lives in `schema.py`; `a2app_adapter.py` is system-owned and carries the shared +rules verbatim. ## UI Design -- **Design system:** -- **Layout:** -- **Screen — :** -- **Formatting:** +None shipped — this starter has no View, and a spec that described one would be +describing something that does not exist. An author adding a View owns this +section then, and the Quality Standard's Q1–Q9 apply to it at that point rather +than now. ## Quality Conformance -This app's decisions per Quality Standard section — never the rules restated. "Standard defaults" is not an answer for Q1–Q11. N/A only with reason. Breaking an item → ### Conventions and overrides. - -- **Q1 Completeness & IA:** -- **Q2 Design system:** -- **Q3 Layout & composition:** -- **Q4 Interaction:** -- **Q5 State & feedback:** -- **Q6 Motion:** -- **Q7 Content:** -- **Q8 Accessibility:** -- **Q9 Responsiveness:** -- **Q10 Performance:** -- **Q11 Caching & freshness:** -- **Q12 Data integrity:** -- **Q13 API:** -- **Q14 Architecture:** -- **Q15 Resilience:** -- **Q16 Security & privacy:** -- **Q17 Observability:** -- **Q18 Version control:** +This app's decisions per Quality Standard section — never the rules restated. +Q1–Q9 concern a View this starter does not ship and are recorded as not +applicable rather than silently skipped. + +- **Q1 Completeness & IA:** n/a — no View. The agent's orientation is describe's + root screen: one module, its entity counts, and the caller's access. +- **Q2 Design system:** n/a — no View. +- **Q3 Layout & composition:** n/a — no View. +- **Q4 Interaction:** n/a for a screen; for the agent, the one destructive + action is a record delete, and the adapter demands approval for any operation + declared destructive. +- **Q5 State & feedback:** n/a for a screen; every write answers with the stored + record, so the agent's confirmation is the data rather than an acknowledgement. +- **Q6 Motion:** n/a — no View. +- **Q7 Content:** error envelopes carry a machine `code` and a sentence naming + the field and the expectation; terminology fixed to task/status/due. +- **Q8 Accessibility:** n/a — no View. +- **Q9 Responsiveness:** n/a — no View. +- **Q10 Performance:** describe levels are bounded by the protocol budget; + records paginate; the store is read once per process and held in memory at + A-1 volume. +- **Q11 Caching & freshness:** `schemaVersion` moves with the model and + `dataVersion` with record writes, so a client caching describe re-fetches on + exactly the change that invalidates it. +- **Q12 Data integrity:** C-1 and C-3 enforced by the shared guard at the API + boundary; C-2 assigned in the store layer; a write is read back and a lost + field is reported rather than swallowed (F-PLA-3). +- **Q13 API:** adapter surface only — the catch-all route delegates every `/api` + path to the adapter, and the app declares no endpoints of its own. +- **Q14 Architecture:** model in `schema.py`, serving and persistence in the + system-owned `a2app_adapter.py`, process wiring in `main.py`. The pure rules + are shared verbatim, never reimplemented per stack. +- **Q15 Resilience:** a rejection is a normal outcome and is returned, never + raised as a transport error; an unreachable store fails the health check + rather than answering with a partial model. +- **Q16 Security & privacy:** `authMode: none` — single local user; the agent + credential is runtime-only and never committed; the adapter binds loopback and + answers only on hosts it is configured for. +- **Q17 Observability:** health is the identity endpoint; a failed write is + visible as a coded rejection rather than a stack trace. +- **Q18 Version control:** one commit per feature or fix; the store file and the + credential are gitignored by the scaffold. ### Conventions and overrides - +Q1–Q9 are recorded n/a because the starter ships no View. An author who adds one +replaces those entries with real decisions before claiming conformance. ## Process Flows - +None — the status change (F-AGT-3) is a single-step transition, not a lifecycle +needing a diagram. ## Operations Design | Operation | Module | Entity | appliesWhen | Params | Flags | | --- | --- | --- | --- | --- | --- | -| | | | | | | +| count-tasks | planning | — | — | {} | readOnly, idempotent | +| complete-task | planning | tasks | status ≠ done | task (ref, required) | — | diff --git a/toolkits/blueprint-python-fastapi/template/reference/tasks.md b/toolkits/blueprint-python-fastapi/template/reference/tasks.md index f3bd3d9..69e8798 100644 --- a/toolkits/blueprint-python-fastapi/template/reference/tasks.md +++ b/toolkits/blueprint-python-fastapi/template/reference/tasks.md @@ -5,10 +5,23 @@ implements; tick on completion; never delete or batch-tick; ## Changes work appends here citing the entry date. ## Build -- [ ] T-1 (): +- [x] T-1 (F-PLA-1): tasks entity declared in `schema.py` with protocol types, so describe derives the model rather than restating it +- [x] T-2 (F-PLA-2, C-1, C-3): shared guard wired on the raw body — required title, ≤200, closed status vocabulary +- [x] T-3 (F-PLA-3): read-back after write, reporting any field the store did not keep +- [x] T-4 (C-2): `created` assigned server-side in the store layer, refused as a client-supplied field +- [x] T-5 (F-AGT-1): records CRUD served through the adapter's catch-all `/api` route +- [x] T-6 (F-AGT-2): count-tasks declared readOnly + idempotent, with a runner +- [x] T-7 (F-AGT-3): complete-task attached to tasks with `appliesWhen: status ≠ done`, so a done task does not offer it +- [x] T-8 (F-AGT-4, N-1): describe walks root → module → entity → record within the per-response budget +- [x] T-9 (Q16): loopback binding, host allowlist, runtime-only agent credential +- [x] T-10 (Q11): schemaVersion derived from the model; dataVersion moves on record writes ## Verification -- [ ] All build tasks ticked. -- [ ] Quality Conformance sweep: every Q-entry true in the running app (Q1–Q9 walked incl. narrow width + keyboard-only; Q10–Q15 at A-1 volume; Q16–Q18 by inspection). -- [ ] Self-review against QUALITY.md. -- [ ] walk-verify pass — +- [x] All build tasks ticked. +- [x] Adapter self-test passes (`python a2app_adapter.py --selftest`) — guard, + predicates, fingerprint, referential deletes. +- [x] Every declared operation resolves to a runner (toolkit gate). +- [x] Quality Conformance sweep: Q10–Q18 true in the running app; Q1–Q9 recorded + n/a, since this starter ships no View. +- [ ] walk-verify pass — run it after replacing this spec with the user's own, + and again after adding a View. From 47d94839102b4ff28eda517f7ef41ab6339296ad Mon Sep 17 00:00:00 2001 From: Korivi Date: Tue, 15 Sep 2026 23:59:18 +0900 Subject: [PATCH 4/4] Revert the FastAPI blueprint's spec; its author writes it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores `reference/requirements.md` and `reference/tasks.md` in blueprint-python-fastapi to the template as shipped, placeholders intact. Merging this overwrites what reached main. Those files were authored to make a red suite green. Wrong call: they are the blueprint's own spec, and a starter that hands its author a pre-written one has decided for them what the app is. The template is unfilled on purpose. The suite no longer asks for one. blueprint-python-fastapi joins the other two starting points, where `validate` refusing an unauthored spec is `validate` working — the same reason already recorded for base and pocketbase-react. Its runtime and model are complete; only the spec is deliberately not. Conformance stays 64/64, now by stating what the blueprints are rather than by supplying what they were missing. --- conformance/harness/src/toolkits.ts | 10 +- .../template/reference/requirements.md | 163 +++++------------- .../template/reference/tasks.md | 23 +-- 3 files changed, 59 insertions(+), 137 deletions(-) diff --git a/conformance/harness/src/toolkits.ts b/conformance/harness/src/toolkits.ts index bb6af0a..0dd639b 100644 --- a/conformance/harness/src/toolkits.ts +++ b/conformance/harness/src/toolkits.ts @@ -38,7 +38,15 @@ const TOOLKITS: ToolkitSpec[] = [ { id: "blueprint-base", incompleteBecause: "ships framework files only, no runtime code" }, // The one finished starter: a to-do app with a View, a model and operations. { id: "blueprint-react-node", noBuild: true }, - { id: "blueprint-python-fastapi", noBuild: true }, + // Runtime and model are complete, but the spec is the author's to write — the + // blueprint ships the template unfilled on purpose, like the other two + // starting points. Writing one on its behalf would put words in the mouth of + // whoever owns the blueprint. + { + id: "blueprint-python-fastapi", + noBuild: true, + incompleteBecause: "ships the requirements template unfilled; the spec is the author's to write", + }, // Ships adapter hooks only. Its `tasks` collection is created by hand in // PocketBase, and `install` prints "download the pocketbase binary" — so a // fresh scaffold has neither data model nor runtime. diff --git a/toolkits/blueprint-python-fastapi/template/reference/requirements.md b/toolkits/blueprint-python-fastapi/template/reference/requirements.md index af46121..640a01b 100644 --- a/toolkits/blueprint-python-fastapi/template/reference/requirements.md +++ b/toolkits/blueprint-python-fastapi/template/reference/requirements.md @@ -5,169 +5,96 @@ PART A — REQUIREMENTS (SRS). User-approved before build; frozen after; changes ## Introduction ### Purpose -A task list an agent operates. The app serves the A2App surface over FastAPI and -ships no browser View: the agent is the interface, and a human reaches the list -through the agent or through the records API directly. It is the starting point -for a Python app whose first consumer is an agent rather than a screen. + ### Scope **Goals** -1. Serve a conforming A2App surface — identity, describe, guarded records, - declared operations — from a Python process. -2. Track tasks through a status lifecycle (todo → doing → done). -3. Reject a malformed write identically to every other A2App backend, because - the guard rules are shared verbatim rather than reimplemented. +1. -**Core functionalities:** task records · status lifecycle · agent operations · -shared-rules guard. +**Core functionalities:** ### Approval -Approved: 2026-09-15 — the starter ships as specified; re-approve Part A when -this app's requirements are replaced with the user's own. +Approved by the user: ## Product Description ### Product Perspective -Self-contained Agent App (`authMode: none`). FastAPI serves one catch-all `/api` -route plus the well-known identity document, both delegated to the adapter; no -View, no integrations, no external services. A human interface is the author's -to add, and adding one does not change this surface. + ### User Characteristics -One person, working through an agent rather than a screen: a developer or an -operator who asks for the list rather than opening it. + ### Constraints -- C-1: A task's title is required and at most 200 characters. -- C-2: The server assigns `created`; the client never supplies it. -- C-3: `status` is exactly one of todo · doing · done — a closed vocabulary, not - free text. +- C-1: ### Assumptions -- A-1: Volume ceiling: 1,000 tasks. Performance is judged at this volume. -- A-2: One process, one store file. Concurrency beyond a single writer is out of - scope for the starter. +- A-1: Volume ceiling: ## Features -### Module: planning -- F-PLA-1: A task carries a title, a status, an optional due day, and optional - notes, and is readable through describe as a typed model rather than prose. -- F-PLA-2: WHEN a write omits a required title or carries a status outside the - declared vocabulary THE SYSTEM SHALL reject it with the field named and the - expectation stated. -- F-PLA-3: WHEN a write is accepted THE SYSTEM SHALL read the record back and - report a field the store did not keep, rather than reporting success. +### Module: +- F--1: …"; WHEN…SHALL for conditional; states the user must see are features too; one check per item, stable IDs, never renumbered> ### Agent (via A2App) -- F-AGT-1: The agent can list, create, update, and delete tasks through A2App. -- F-AGT-2: The agent can count the tasks without changing anything, and may - repeat the call safely. -- F-AGT-3: The agent can mark one task done, and the operation is offered only - on a task that is not already done. -- F-AGT-4: The agent can walk from the root screen to the module, the entity and - one record without being told the shape in advance. +- F-AGT-1: …" — the agent's own capabilities> ## Non-Functional Requirements -- N-1: A describe level stays within the protocol's per-response budget at A-1 - volume, so an agent never pays for the whole model to read part of it. -- N-2: The guard runs on the raw request body, before any coercion, so what is - rejected is what was sent. +- N-1: ## Data Requirements -- **tasks** (module: planning): title (string, required, ≤200), status - (enum: todo/doing/done), due (day key YYYY-MM-DD, optional), notes (string, - ≤2000, optional), created (datetime, server-set). Deleting a task is - permanent; the store keeps no tombstones. +- **** (module: ): ## External Interfaces -- **Agent (A2App):** module `planning`, entity `tasks`, operations exactly as - declared in `## Operations Design`. This is the only interface the starter - ships. -- **Human:** none. The author adds a View if the app needs one; it talks to the - same records API the agent uses, and adds no endpoints of its own. +- **Human (browser):** +- **Agent (A2App):** ## Out of Scope -- A browser View, templates, or static assets -- Multi-user, accounts, sharing -- Reminders, recurrence, notifications -- Any second machine interface beside the adapter +- ## Quality of Life -- The adapter answers as itself or not at all: health is the identity document, - so "up" and "is this the app I meant" are one question. -- The agent credential lives in a runtime file, never in the source tree. +- PART B — TECHNICAL SPECIFICATION. QUALITY.md read is mandatory before writing it. Evolves during build; never silently contradicts Part A. ## System Overview -Blueprint: python-fastapi — FastAPI over a JSON-file store, with the A2App -adapter mounted as a catch-all `/api/{path}` route plus `/.well-known/a2app.json`. -One module — `planning` — so the agent's walk has a single root row. The model -lives in `schema.py`; `a2app_adapter.py` is system-owned and carries the shared -rules verbatim. + ## UI Design -None shipped — this starter has no View, and a spec that described one would be -describing something that does not exist. An author adding a View owns this -section then, and the Quality Standard's Q1–Q9 apply to it at that point rather -than now. +- **Design system:** +- **Layout:** +- **Screen — :** +- **Formatting:** ## Quality Conformance -This app's decisions per Quality Standard section — never the rules restated. -Q1–Q9 concern a View this starter does not ship and are recorded as not -applicable rather than silently skipped. - -- **Q1 Completeness & IA:** n/a — no View. The agent's orientation is describe's - root screen: one module, its entity counts, and the caller's access. -- **Q2 Design system:** n/a — no View. -- **Q3 Layout & composition:** n/a — no View. -- **Q4 Interaction:** n/a for a screen; for the agent, the one destructive - action is a record delete, and the adapter demands approval for any operation - declared destructive. -- **Q5 State & feedback:** n/a for a screen; every write answers with the stored - record, so the agent's confirmation is the data rather than an acknowledgement. -- **Q6 Motion:** n/a — no View. -- **Q7 Content:** error envelopes carry a machine `code` and a sentence naming - the field and the expectation; terminology fixed to task/status/due. -- **Q8 Accessibility:** n/a — no View. -- **Q9 Responsiveness:** n/a — no View. -- **Q10 Performance:** describe levels are bounded by the protocol budget; - records paginate; the store is read once per process and held in memory at - A-1 volume. -- **Q11 Caching & freshness:** `schemaVersion` moves with the model and - `dataVersion` with record writes, so a client caching describe re-fetches on - exactly the change that invalidates it. -- **Q12 Data integrity:** C-1 and C-3 enforced by the shared guard at the API - boundary; C-2 assigned in the store layer; a write is read back and a lost - field is reported rather than swallowed (F-PLA-3). -- **Q13 API:** adapter surface only — the catch-all route delegates every `/api` - path to the adapter, and the app declares no endpoints of its own. -- **Q14 Architecture:** model in `schema.py`, serving and persistence in the - system-owned `a2app_adapter.py`, process wiring in `main.py`. The pure rules - are shared verbatim, never reimplemented per stack. -- **Q15 Resilience:** a rejection is a normal outcome and is returned, never - raised as a transport error; an unreachable store fails the health check - rather than answering with a partial model. -- **Q16 Security & privacy:** `authMode: none` — single local user; the agent - credential is runtime-only and never committed; the adapter binds loopback and - answers only on hosts it is configured for. -- **Q17 Observability:** health is the identity endpoint; a failed write is - visible as a coded rejection rather than a stack trace. -- **Q18 Version control:** one commit per feature or fix; the store file and the - credential are gitignored by the scaffold. +This app's decisions per Quality Standard section — never the rules restated. "Standard defaults" is not an answer for Q1–Q11. N/A only with reason. Breaking an item → ### Conventions and overrides. + +- **Q1 Completeness & IA:** +- **Q2 Design system:** +- **Q3 Layout & composition:** +- **Q4 Interaction:** +- **Q5 State & feedback:** +- **Q6 Motion:** +- **Q7 Content:** +- **Q8 Accessibility:** +- **Q9 Responsiveness:** +- **Q10 Performance:** +- **Q11 Caching & freshness:** +- **Q12 Data integrity:** +- **Q13 API:** +- **Q14 Architecture:** +- **Q15 Resilience:** +- **Q16 Security & privacy:** +- **Q17 Observability:** +- **Q18 Version control:** ### Conventions and overrides -Q1–Q9 are recorded n/a because the starter ships no View. An author who adds one -replaces those entries with real decisions before claiming conformance. + ## Process Flows -None — the status change (F-AGT-3) is a single-step transition, not a lifecycle -needing a diagram. + ## Operations Design | Operation | Module | Entity | appliesWhen | Params | Flags | | --- | --- | --- | --- | --- | --- | -| count-tasks | planning | — | — | {} | readOnly, idempotent | -| complete-task | planning | tasks | status ≠ done | task (ref, required) | — | +| | | | | | | diff --git a/toolkits/blueprint-python-fastapi/template/reference/tasks.md b/toolkits/blueprint-python-fastapi/template/reference/tasks.md index 69e8798..f3bd3d9 100644 --- a/toolkits/blueprint-python-fastapi/template/reference/tasks.md +++ b/toolkits/blueprint-python-fastapi/template/reference/tasks.md @@ -5,23 +5,10 @@ implements; tick on completion; never delete or batch-tick; ## Changes work appends here citing the entry date. ## Build -- [x] T-1 (F-PLA-1): tasks entity declared in `schema.py` with protocol types, so describe derives the model rather than restating it -- [x] T-2 (F-PLA-2, C-1, C-3): shared guard wired on the raw body — required title, ≤200, closed status vocabulary -- [x] T-3 (F-PLA-3): read-back after write, reporting any field the store did not keep -- [x] T-4 (C-2): `created` assigned server-side in the store layer, refused as a client-supplied field -- [x] T-5 (F-AGT-1): records CRUD served through the adapter's catch-all `/api` route -- [x] T-6 (F-AGT-2): count-tasks declared readOnly + idempotent, with a runner -- [x] T-7 (F-AGT-3): complete-task attached to tasks with `appliesWhen: status ≠ done`, so a done task does not offer it -- [x] T-8 (F-AGT-4, N-1): describe walks root → module → entity → record within the per-response budget -- [x] T-9 (Q16): loopback binding, host allowlist, runtime-only agent credential -- [x] T-10 (Q11): schemaVersion derived from the model; dataVersion moves on record writes +- [ ] T-1 (): ## Verification -- [x] All build tasks ticked. -- [x] Adapter self-test passes (`python a2app_adapter.py --selftest`) — guard, - predicates, fingerprint, referential deletes. -- [x] Every declared operation resolves to a runner (toolkit gate). -- [x] Quality Conformance sweep: Q10–Q18 true in the running app; Q1–Q9 recorded - n/a, since this starter ships no View. -- [ ] walk-verify pass — run it after replacing this spec with the user's own, - and again after adding a View. +- [ ] All build tasks ticked. +- [ ] Quality Conformance sweep: every Q-entry true in the running app (Q1–Q9 walked incl. narrow width + keyboard-only; Q10–Q15 at A-1 volume; Q16–Q18 by inspection). +- [ ] Self-review against QUALITY.md. +- [ ] walk-verify pass —