Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion adapters/adapter-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 && node test/whoami.test.mjs"
"test": "tsc -p tsconfig.json && node test/static.test.mjs && node test/host.test.mjs && node test/whoami.test.mjs && node test/delete-guard.test.mjs"
}
}
92 changes: 92 additions & 0 deletions adapters/adapter-core/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
describeViolation,
describeIncomplete,
labelFieldOf,
onDeleteOf,
schemaFingerprint,
ERROR_CODES,
type EntityPrint,
Expand Down Expand Up @@ -59,6 +60,13 @@ const PROTOCOL_VERSION = "0.1";
* on.
*/
const DEFAULT_CREDENTIAL_HINT = "Read the app's .agent-token file (mode 0600) in the project directory.";
/** Page size for the referential scan a delete runs before it commits. */
const REFERENCE_SCAN_PAGE = 500;
/** How many blocking record ids are reported per field. The answer is "yes, and
* here are examples" — a caller does not need ten thousand of them. */
const REFERENCE_SCAN_LIMIT = 10;
/** Hard stop on paging, so a binding that ignores `page` cannot spin forever. */
const REFERENCE_SCAN_MAX_PAGES = 200;
const TASK_TIMEOUT_MS = 60_000;
const TASK_MAX_DELIVERIES = 5;
const DESCRIBE_PREFIX = "/api/_a2app/describe/";
Expand Down Expand Up @@ -584,6 +592,59 @@ export function createA2App(binding: Binding, config: A2AppConfig): A2App {

/* ------------------------------------------------------------- records */

/**
* Who still points at this record.
*
* Every `ref` and `list<ref>` in the model names the entity it targets, so the
* app has ALREADY told the framework where its references live — this reads
* that declaration rather than asking for a second one.
*
* Scans referencing entities page by page instead of filtering server-side:
* `filter` is interpreted by the binding, and a policy that silently did
* nothing against a binding whose filter grammar differed would be worse than
* no policy at all. Only entities that actually declare a `restrict` ref to
* this one are read, so an entity nothing points at costs nothing.
*
* Stops at {@link REFERENCE_SCAN_LIMIT} matches: the answer is "yes, and here
* are examples", and listing ten thousand blocking rows helps nobody.
*/
async function referencesTo(entity: string, id: string): Promise<{ entity: string; field: string; ids: string[] }[]> {
const model = binding.entities();
const blockers: { entity: string; field: string; ids: string[] }[] = [];

for (const [other, def] of Object.entries(model)) {
const pointing = def.fields.filter(
(f) => (f.type === "ref" || f.type === "list<ref>") && f.entity === entity && onDeleteOf(f) === "restrict",
);
if (pointing.length === 0) continue;

const found = new Map<string, string[]>();
let page = 1;
// Bounded: stop once every pointing field has its examples, or the pages
// run out. A page that comes back short is the last one.
for (;;) {
const result = await binding.listRecords(other, { page, perPage: REFERENCE_SCAN_PAGE });
const items = result.items ?? [];
for (const row of items) {
for (const field of pointing) {
const value = (row as Record<string, unknown>)[field.name];
const hit = Array.isArray(value) ? value.includes(id) : value === id;
if (!hit) continue;
const ids = found.get(field.name) ?? [];
if (ids.length < REFERENCE_SCAN_LIMIT) ids.push(String(row["id"] ?? ""));
found.set(field.name, ids);
}
}
if (items.length < REFERENCE_SCAN_PAGE) break;
page++;
if (page > REFERENCE_SCAN_MAX_PAGES) break;
}

for (const [field, ids] of found) blockers.push({ entity: other, field, ids });
}
return blockers;
}

async function handleRecords(
req: A2AppRequest,
entity: string,
Expand Down Expand Up @@ -635,6 +696,37 @@ export function createA2App(binding: Binding, config: A2AppConfig): A2App {

if (req.method === "DELETE") {
if (!recordId) return err(400, "usage", "DELETE requires a record id.");

// The app's referential rules bind THIS door too.
//
// An app that guards deletion inside an operation has guarded exactly one
// way in: its own UI. This generic record route is the other, and it used
// to go straight to the store — so the rule held until an agent took the
// path the rule did not cover, and the orphan it left looked like a
// successful delete. The check belongs here, in the code an app author
// cannot edit, because that is the only place BOTH doors pass through.
const blockers = await referencesTo(entity, recordId);
if (blockers.length > 0) {
const total = blockers.reduce((n, b) => n + b.ids.length, 0);
const where = blockers.map((b) => `${b.entity}.${b.field}`).join(", ");
writeAudit(ctx, `data:${entity}`, recordId, "rejected", ERROR_CODES.RECORD_REFERENCED);
return err(
409,
ERROR_CODES.RECORD_REFERENCED,
`Cannot delete ${entity} "${recordId}": ${
total === 1 ? "a record still references" : `${total} records still reference`
} it (${where}).`,
{
referencedBy: blockers,
// Say what would make the delete legal, so the caller has a next
// move instead of a wall.
resolution:
`Remove or repoint the referencing records first, or run an operation the app provides for this. ` +
`An app that intends references to outlive the record declares \`onDelete: "ignore"\` on the ref.`,
},
);
}

const okDel = await binding.deleteRecord(entity, recordId);
writeAudit(ctx, `data:${entity}`, recordId, okDel ? "ok" : "rejected");
if (!okDel) return err(404, "record_not_found", `No ${entity} record "${recordId}".`);
Expand Down
175 changes: 175 additions & 0 deletions adapters/adapter-core/test/delete-guard.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* Deleting a record that other records still point at.
*
* An app that guards deletion inside an operation has guarded one way in: its
* own UI. The generic record route — `DELETE /api/collections/{entity}/records/{id}`,
* reached by `a2app <app> data <entity> delete <id>` — is the other, and it used
* to go straight to the store. So the rule held right up until an agent took the
* path the rule did not cover, and the orphan it left behind was reported as a
* successful delete.
*
* The check cannot live in the app: record deletion is adapter code, which
* toolkit-sync overwrites. So the app declares the relationship it already has
* to declare — a `ref` names the entity it targets — and the adapter enforces it
* on the one path both doors pass through.
*
* 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);
};

/** An invoicing app: invoices point at a client, and may cite several projects. */
const build = (onDelete) =>
createA2App(
new MemoryBinding({
appId: "billing",
appName: "Billing",
entities: {
clients: {
module: "sales",
fields: [{ name: "name", type: "string", required: true }],
seed: [
{ id: "c_acme", name: "Acme" },
{ id: "c_unused", name: "Nobody" },
],
},
projects: {
module: "sales",
fields: [{ name: "title", type: "string", required: true }],
seed: [{ id: "p_one", title: "One" }],
},
invoices: {
module: "sales",
fields: [
{ name: "total", type: "number" },
// The relationship the app already declares. Nothing extra is asked
// of the author for the default to apply.
{ name: "client", type: "ref", entity: "clients", ...(onDelete ? { onDelete } : {}) },
{ name: "projects", type: "list<ref>", entity: "projects" },
],
seed: [{ id: "inv_1", total: 100, client: "c_acme", projects: ["p_one"] }],
},
},
}),
{
modules: [{ name: "sales", summary: "clients and what they owe" }],
credentials: [
{ token: "t", credentialId: "cred", agentName: "test", principal: "owner", scopes: ["*"] },
],
},
);

const call = (app, method, path, body) =>
app.handle({ method, path, headers: { "x-a2app-token": "t" }, query: {}, body });

const del = (app, entity, id) => call(app, "DELETE", `/api/collections/${entity}/records/${id}`);

/* ------------------------------------------- the reported hole, closed */

{
const app = build();
const res = await del(app, "clients", "c_acme");
check("a referenced client is not deleted", res.status, 409);
check("and says why, by code", res.json.code, "record_referenced");
ok("the message names the record", /c_acme/.test(res.json.message));
ok("and where the references are", /invoices\.client/.test(res.json.message));

// A caller that is refused needs to know WHICH records are in the way; "no"
// with no location is a wall, not an answer.
// Read through a blank rather than indexing directly: when this regresses the
// delete simply succeeds, and a test that throws on the first missing field
// reports one TypeError instead of the list of things that are now wrong.
const by = Array.isArray(res.json.referencedBy) ? res.json.referencedBy : [];
const first = by[0] ?? {};
ok("the blocking records are reported", by.length === 1);
check("naming the entity", first.entity, "invoices");
check("and the field", first.field, "client");
check("and the record ids", first.ids, ["inv_1"]);
ok("with a stated way forward", typeof res.json.resolution === "string" && res.json.resolution.length > 0);

// The point of the guard: nothing was half-done.
const client = await call(app, "GET", "/api/collections/clients/records/c_acme");
check("the client is still there", client.status, 200);
const invoice = await call(app, "GET", "/api/collections/invoices/records/inv_1");
check("the invoice is untouched", invoice.status, 200);
check("still pointing at its client", invoice.json.client, "c_acme");
}

/* --------------------------------------------- what must still work */

{
const app = build();
const res = await del(app, "clients", "c_unused");
check("a client nobody references still deletes", res.status, 200);
check("and reports what it deleted", res.json.deleted, "c_unused");
}

{
// Removing the reference removes the block — the guard is about the current
// state, not a permanent mark on the record.
const app = build();
const first = await del(app, "clients", "c_acme");
check("blocked while the invoice exists", first.status, 409);
const dropped = await del(app, "invoices", "inv_1");
check("the invoice itself deletes", dropped.status, 200);
const second = await del(app, "clients", "c_acme");
check("and now the client does too", second.status, 200);
}

/* ------------------------------------------------ list<ref> counts too */

{
// A reference held in a list is still a reference. Checking only scalar refs
// would leave exactly the same hole, one type over.
const app = build();
const res = await del(app, "projects", "p_one");
check("a project cited in a list<ref> is not deleted", res.status, 409);
check("with the same code", res.json.code, "record_referenced");
const cited = (Array.isArray(res.json.referencedBy) ? res.json.referencedBy : [])[0] ?? {};
check("naming the list field", cited.field, "projects");
check("and the record holding it", cited.ids, ["inv_1"]);
}

/* --------------------------------------------- the deliberate opt-out */

{
// An app that means for references to outlive the record says so, and then it
// is a decision recorded in the schema rather than an accident.
const app = build("ignore");
const res = await del(app, "clients", "c_acme");
check('onDelete "ignore" allows the delete', res.status, 200);
const invoice = await call(app, "GET", "/api/collections/invoices/records/inv_1");
check("the invoice survives", invoice.status, 200);
check("with its reference left as declared", invoice.json.client, "c_acme");

// The opt-out is per field: the list<ref> never asked for it.
const stillGuarded = await del(app, "projects", "p_one");
check("the other ref is still guarded", stillGuarded.status, 409);
}

/* ------------------------------------------------------ absent record */

{
const app = build();
const res = await del(app, "clients", "c_missing");
check("a record that does not exist is still 404, not 409", res.status, 404);
check("with its own code", res.json.code, "record_not_found");
}

/* ---------------------------------------------------------------- report */

if (failures.length > 0) {
console.error(`delete-guard: ${failures.length} failure(s)\n`);
for (const f of failures) console.error(" " + f);
process.exit(1);
}
console.log("delete-guard: all checks passed");
39 changes: 39 additions & 0 deletions adapters/rules/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,24 @@ export const PROTOCOL_TYPES = [

export type ProtocolType = (typeof PROTOCOL_TYPES)[number];

/** Referential policies a `ref` may declare. See {@link NormalizedField.onDelete}. */
export const ON_DELETE = ["restrict", "ignore"] as const;
export type OnDelete = (typeof ON_DELETE)[number];

/**
* What a `ref` does when nothing says otherwise: refuse the delete.
*
* Silently orphaning is the worse default of the two. It is invisible at the
* moment it happens, and the app that has to cope with it is the one reading the
* record weeks later.
*/
export const DEFAULT_ON_DELETE: OnDelete = "restrict";

/** The policy in force for a field — the declared one, or the default. */
export function onDeleteOf(field: Pick<NormalizedField, "onDelete">): OnDelete {
return field.onDelete ?? DEFAULT_ON_DELETE;
}

/** The A2App error code registry. Clients branch on `code`, never on message
* prose. Unknown codes are treated as rejection, never success. */
export const ERROR_CODES = {
Expand All @@ -102,6 +120,8 @@ export const ERROR_CODES = {
TASK_CANCELED: "task_canceled",
AGENT_TOKEN_REQUIRED: "agent_token_required",
RATE_LIMITED: "rate_limited",
/** The record is still referenced, and a `ref` pointing at it says `restrict`. */
RECORD_REFERENCED: "record_referenced",
} as const;

/**
Expand All @@ -120,6 +140,25 @@ export interface NormalizedField {
values?: string[];
/** target entity for `ref` / `list<ref>` */
entity?: string;
/**
* What deleting the REFERENCED record does to this reference — `ref` and
* `list<ref>` only. Default {@link DEFAULT_ON_DELETE}.
*
* - `restrict` — refuse the delete while any record still points here.
* - `ignore` — allow it, and leave this reference dangling.
*
* It defaults to refusing because the alternative fails silently: the delete
* reports success and the damage surfaces later, somewhere else, as a record
* pointing at nothing. An app that genuinely wants dangling references says so
* in one word, and then it is a decision on the record rather than an accident.
*
* There is deliberately no `cascade` or `detach`. Both would have one delete
* quietly write to records the caller never named, which an agent cannot
* approve in advance and an audit log cannot explain afterwards. An app that
* wants that declares an operation, where it is named, described and approved
* like any other write.
*/
onDelete?: OnDelete;
/** true for a `string` field carrying a `YYYY-MM-DD` day key */
dayKey?: boolean;
}
Expand Down
Loading
Loading