Skip to content

Route writes: the route grant, quotas and provenance (#1717) - #1754

Closed
michielbdejong wants to merge 1 commit into
claude/plugin-well-knownfrom
claude/plugin-route-writes
Closed

michielbdejong wants to merge 1 commit into
claude/plugin-well-knownfrom
claude/plugin-route-writes

Conversation

@michielbdejong

Copy link
Copy Markdown
Contributor

Closes #1717. Stacked on #1752 (well-known), which is on #1751 (route execution), #1749 (registry), #1732 (manifest v3) and #1726 (gates).

Design: atomic-plugins docs/design/server-plugin-routes.md (ontola/atomic-plugins#108), section 2.6 and decision D4.

What it does

Everything that applies writes is behind #[cfg(feature = "plugin-routes")] and only runs at --plugin-routes read-write. At read-write, the 501 route-writes-unavailable from #1751 is gone for intents. Code: new server/src/plugins/route_writes.rs, called from route_exec.rs.

Order of a writing request

  1. The handler's response is validated first. An invalid response is 502, and nothing is written.
  2. The intents are planned by the shared planner (plan_verdict).
  3. The plan is checked against the route grant, the write targets and the quotas.
  4. It is applied by the shared applier (apply_plan with StoreApplyHost), signed by the installation's agent.
  5. Only then is the response sent, so a 2xx means the write was stored.

Route grant

The route grant is part of the Installation's grants: {"route-writes": [write targets]}.

  • It can be an object element of the array form (["storage", {"route-writes": [...]}]) or a key of the object form.
  • check_grants accepts it in every build that can install plugins, next to the capability names. It is not a capability.
  • Activation refuses a release whose writeTargets the grant does not list unchanged. So an upgrade that widens the targets needs a new review, and the old release keeps serving. A narrower release passes.
  • Without a grant the release still installs, and its read routes serve. Every write answers 403 route-write-not-granted.

What a route may write

  • Creates: only directly under a target's parent, with classes from that target. The parent is config:<key> from the Installation's config, or a URL.
  • Sets, removes and destroys: only on resources this installation created that are still under a target with its classes.
    • "Created" means the genesis signer: the verified genesis certificate, or the signed genesis commit whose signature is the DID.
    • createdBy is never trusted.
  • Protected properties: parent, isA, read, write, append, genesis, createdBy and routeProvenance can never be written through a route.
  • Refusals: anything outside these rules is 502 route-write-refused, and nothing is written. An intent that doesn't plan is 502 route-write-invalid.

Quotas (per installation; 0 turns one off)

Quota Default Option / env
Creates per remote caller per hour 100 (design, proposed) --plugin-route-creates-per-caller-hour / ATOMIC_PLUGIN_ROUTE_CREATES_PER_CALLER_HOUR
Creates per installation per day 10,000 (design, proposed) --plugin-route-creates-per-day / ATOMIC_PLUGIN_ROUTE_CREATES_PER_DAY
Bytes written per installation per day 64 MiB (my choice, the design has no number) --plugin-route-bytes-per-day / ATOMIC_PLUGIN_ROUTE_BYTES_PER_DAY
  • Going past a quota answers 429 route-quota-exceeded with Retry-After (seconds until the window ends), and nothing is written.
  • The whole write is checked and booked under one lock, so two concurrent requests can't both slip under the same limit.
  • If nothing was stored, the booking is refunded.

Provenance

Every created or changed resource is signed by the installation's agent and gets a new JSON property, https://atomicdata.dev/properties/routeProvenance (added to lib/defaults/plugins.json):

{ "installation": "<Installation>", "route": "inbox", "request": "http:<ulid>", "caller": null, "receivedAt": 1790276444383 }

Unchanged

  • At read-only: every intent or enqueue is refused (502), and a release with write targets doesn't install.
  • Enqueues: refused whole (501 route-enqueue-unavailable) until the delivery queue (Durable delivery queue for route-enqueued operations #1719), even when the verdict also has intents.
  • integrationWaits: refused (502).

Fixture

testdata/plugin-routes/inbox/ is a v3 drive-prefix plugin with one write target, inbox-items (parent config:inbox, class PlainText). It is exposed as test_fixture::inbox_release(). Its routes:

  • POST /inbox creates an item;
  • GET /items lists the items as the public agent;
  • PUT / DELETE /item change or delete an item.

For the tests, the body may name another parent, class or subject.

Tests

  • route_writes::http_tests (real execution through the app, read-write):
    • a POST is stored under the inbox, signed by the installation's agent, with provenance; an update and a delete of its own item work, and the update carries the new request's provenance;
    • a create under another parent, a create with another class, and an update or delete of someone else's resource under the inbox are refused (502), and nothing is written or changed;
    • the per-caller quota at 2: the third POST gets 429 + Retry-After, and exactly 2 items exist;
    • without a route grant: 403, nothing written;
    • a grant that doesn't cover the targets refuses the install;
    • at read-only, the writing release doesn't install.
  • route_writes::tests: the ledger's windows (per caller and hour, per installation and day, bytes), a whole write refused, refunds, 0 meaning off, and the design defaults.
  • release::tests::the_route_grant_must_cover_every_write_target: both grant forms, no grant, a narrower grant refused, malformed grants refused.
  • route_exec (existing): a route that doesn't declare writes is still refused at read-only and read-write.
  • tests/it/plugin_routes.rs: a new server at read-write. The installer creates the inbox, installs with the route grant and config, and lets the plugin's agent write to the inbox. An anonymous POST gets 202. The item reads back through GET /items (with provenance) and through the client from the store. A POST outside the target gets 502.

Runs:

  • cargo test -p atomic-server --lib: 347 passed with --features plugin-routes, 299 without.
  • cargo clippy -p atomic-server --all-targets -- -D warnings: clean both ways.
  • cargo test -p atomic-server --features plugin-routes --test it plugin_routes: passes (3 tests).
  • cargo test -p atomic_lib --features db-redb --lib populate: passes (new default property).

Interpretations

  • The grant's shape. The design says "the install review approves a route grant". I store the approved targets themselves, not a yes/no. That is what makes "an upgrade that widens writeTargets needs a new review" enforceable at activation.
  • The target's parent must be writable by the installation's agent. Writes go through StoreApplyHost, which checks the agent's rights. So the installer has to give that agent write rights on the target parent, as the tests do. Nothing grants it automatically.
  • Creates go directly under the target parent. Nested creates (a child of a just-created item) are refused.
  • Anonymous callers are keyed by socket peer address for the per-caller quota, like the rate limits in Run plugin routes: the http trigger and route execution (AS-05) #1751. The address is never stored; provenance records caller: null.
  • The quota counts and the byte count are in memory and reset on restart, like the route status counts.
    • "Bytes" means the length of the property URLs plus the serialized values (plus the class URLs for creates), for creates and changes alike.
    • The daily windows are UTC days, and the hourly windows are clock hours.
  • Status codes the design doesn't fix:
    • 403 for a missing grant;
    • 502 for writes outside the targets, or intents that don't plan;
    • 500 route-write-failed if the store fails partway through (the only case where part of a write may be stored);
    • 501 for enqueues.
  • The request's problems don't block the write. Only intents are planned. problems stay in the run log.

🤖 Generated with Claude Code

At `--plugin-routes read-write`, a route verdict's intents are applied
into the route's declared write targets, signed by the installation's
agent, through the shared planner and applier, before the response is
sent.

- Route grant: `{"route-writes": [write targets]}` in the Installation's
  grants. Without it writes answer 403; activation refuses a release whose
  targets the grant does not list, so a widened upgrade needs a new review.
- Creates only directly under a target's parent with its classes; sets,
  removes and destroys only on resources the installation created (genesis
  signer), still inside a target. Parent, class, rights and provenance
  properties are never writable through a route.
- Quotas per installation: creates per remote caller per hour (100), creates
  per day (10,000), bytes per day (64 MiB), configurable; 429 with
  Retry-After and nothing written.
- `routeProvenance` on every created or changed resource: installation,
  route, request id, verified caller.
- Enqueues stay refused (501) until the delivery queue; read-only still
  refuses every write.

Fixture `testdata/plugin-routes/inbox/`; unit tests through the app and the
`plugin_routes` integration test POST and read the item back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@michielbdejong

Copy link
Copy Markdown
Contributor Author

Merge-risk triage (2026-09-28). This is a read-only review of this PR's own diff. The rule it applies: nothing that works today may break for existing users or clients, and new behaviour is added alongside the old. The stack is being rebased onto develop as one chain before merging, so line references are against the current stacked base.

Verdict: SAFE

Gated. The only change in default builds: check_grants also accepts a route-writes grant, which does nothing without the feature, and existing grants are checked exactly as before. Route writes can't touch parent/isA/rights/createdBy/genesis/routeProvenance, and only reach resources this installation created, proven by the genesis signature.

Note: Design note, not a blocker: the grant adds the plugin agent to write on the target parent, and that is inherited by the whole subtree. The route-level rules don't limit other code that acts as that agent (e.g. scheduled runs).

🤖 Generated with Claude Code

@michielbdejong

Copy link
Copy Markdown
Contributor Author

Closing as part of the plugin-host stack cleanup (Q-101): not needed by atomic-plugins now. See the inventory at #1726 (comment). The branch is kept, and pin candidates are unaffected. Closed by Claude (VPS coordinator) on behalf of Michiel.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant