Skip to content

feat(cards): capability cards — an agent made discoverable, by its owner - #80

Open
ExposureGuard wants to merge 1 commit into
mainfrom
feat/capability-cards
Open

ExposureGuard wants to merge 1 commit into
mainfrom
feat/capability-cards

Conversation

@ExposureGuard

Copy link
Copy Markdown
Owner

Stacked on #79 → #78 → #77. Merge order: #77 → #78 → #79 → this.

The register answers "which agents do we run". This answers "which of them are we willing to be found by": an owner-published, capability-only card, listed at /.well-known/agents.json. It is the first part of Haldir that faces outward, so the honesty rules are structural:

The public projection is built by naming what it includes. public_cards() selects its columns — it does not copy a row and delete the private fields. A column added later cannot leak by default, and a test asserts the card's key set is exactly the documented seven for an agent with real spend and flags on the record. (That test also compares numbers, not substrings: "50" appears inside the published_at epoch, and a test that fails on a timestamp is a test nobody trusts.)

Opt-in per agent; withdrawal means deletion. unpublish deletes the row — "hidden" and "gone" are different promises to an operator, and only one is worth making.

Published, not verified. The index says so in its own document. A directory that implies endorsement turns somebody else's overstatement into our misrepresentation.

An agent must exist to be published (the route checks the register first), and one tenant cannot see, change, or withdraw another's card — a foreign card returns the same 404 as a nonexistent one, so the route is not an existence oracle.

Also here

  • The register marks discoverability: card_id per entry, discoverable_agents in the summary. An operator looking at the list needs to know which listing to withdraw.
  • Agents vs principals. admin:<key-prefix> is how _audit_admin attributes an operator's action; system is the deployment's own bookkeeping. They acted, so they are in the register — but a register of AI systems that counts an operator's key as an agent is wrong in the direction nobody notices. summary.agents counts systems, summary.principals counts the rest, summary.entries is everything. (Found by running it: the first smoke test showed admin:hld_… listed as an agent.)
  • haldir publish / haldir unpublish, and migration 010 with a test that the migration and the store create the same table — two definitions of one table is how approval_requests ended up without its tenant column on one path.
  • /docs and llms.txt updated; the public index is in the agent-facing doc because agents are who it is for.

Two test-suite fixes this surfaced

  1. The new modules release the shared rate-limit budget when they finish. They were starving later modules into 429s for reasons that had nothing to do with them — the suite accumulates usage against one bootstrap key.
  2. Covered above: the numeric comparison.

Verification

1137 tests, flake8 clean, mypy clean over 32 files, wheel inspected — haldir_cards.py and migrations/010_agent_cards.sql both ship. openapi.json regenerated (89 paths).

What this does not do

It does not turn the index on for anyone who has not asked: the table is empty until an operator publishes an agent, and the public document is a plain JSON list with an explicit "not verified" note. The cross-tenant data idea we discussed is deliberately not here — that needs consent in the contract, not just a flag in the schema.

🤖 Generated with Claude Code

The register answers "which agents do we run". This answers "which of them are
we willing to be found by": an owner-published, capability-only card, listed
at `/.well-known/agents.json`. It is the first part of Haldir that faces
outward, so the rules that keep it honest are structural rather than
aspirational:

**The public projection is built by naming what it includes.**
`public_cards()` selects its columns; it does not copy a row and delete the
private fields. A column added to the table later cannot leak by default —
and a test asserts the card's key set is exactly the documented seven, for an
agent that has real spend and flags on the record.

**Opt-in per agent; withdrawal means deletion.** Publishing names one agent.
`unpublish` deletes the row, because "hidden" and "gone" are different
promises and only one of them is worth making to an operator.

**Published, not verified.** The index says so in its own document. A
directory that implies endorsement turns somebody else's overstatement into
our misrepresentation.

**An agent must exist to be published** — the route checks the register
first, so a listing always refers to an agent this deployment has seen — and
one tenant cannot see, change, or withdraw another's card. The 404 for a
foreign card is the same 404 as for a nonexistent one.

Also in this change: the register now says which of an operator's agents are
discoverable (`card_id` per entry, `discoverable_agents` in the summary) and
distinguishes **agents from principals** — `admin:<key-prefix>` is how
`_audit_admin` attributes an operator's action, and `system` is the
deployment's own bookkeeping. They acted, so they belong in the register; but
a register of *AI systems* that counts an operator's key as an agent is wrong
in the direction nobody notices. `summary.agents` counts systems,
`summary.principals` counts the rest, `summary.entries` is everything.

`haldir publish <agent> --name ... [--capability X]...` and
`haldir unpublish <agent>` for the terminal; `POST/DELETE
/v1/agents/<id>/card` for everything else. Migration 010, and a test that the
migration and the store create the *same* table — two definitions of one
table is how `approval_requests` ended up without its tenant column on one
path.

Two test-suite fixes this surfaced: the new modules release the shared
rate-limit budget when they finish (they were starving later modules into
429s), and the spend-leak assertion compares numbers rather than substrings
("50" appears inside the published_at epoch).

Verified: 1137 tests, flake8 clean, mypy clean over 32 files, wheel inspected
— the module and the migration both ship.

Co-Authored-By: Claude Code <noreply@anthropic.com>

This branch has not been deployed

No deployments
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