Skip to content

feat: a registry row refuses or replaces each identity hint by writing the list - #204

Merged
vjovanov merged 4 commits into
mainfrom
fix/issue-189
Oct 8, 2026
Merged

vjovanov merged 4 commits into
mainfrom
fix/issue-189

Conversation

@vjovanov

@vjovanov vjovanov commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #189

A project's checkout hints four identity lists in its ephor.json, and anyone who can push to that repository can edit them:

{ "identity": {
    "aliases":   ["the widget"],
    "territory": ["acme"],
    "addresses": ["alice@example.org"],
    "rooms":     ["whatsapp/acme#1@g.us"] } }

Three registry rows point at that checkout. One says nothing, one writes [] for every list, and one writes its own lists:

{ "id": "silent",       "root": "…" },
{ "id": "says-none",    "root": "…", "aliases": [], "territory": [], "addresses": [], "rooms": [] },
{ "id": "says-its-own", "root": "…", "aliases": ["gizmo"], "territory": ["acme/plugin"],
                                     "addresses": ["bob@example.org"], "rooms": ["whatsapp/acme#2@g.us"] }

On main, only rooms follows the row. An empty aliases or territory is read as silence, and nothing reads a row's addresses, so alice@example.org is the project's address whatever the row says. This is what Placement::identity() compiles, as the issue measured it:

silent        aliases=["the widget"] territory=["acme"] addresses=["alice@example.org"] rooms=["whatsapp/acme#1@g.us"]
says-none     aliases=["the widget"] territory=["acme"] addresses=["alice@example.org"] rooms=[]
says-its-own  aliases=["gizmo"] territory=["acme/plugin"] addresses=["alice@example.org"] rooms=["whatsapp/acme#2@g.us"]

With this change, each row gets exactly what it wrote, and only the silent row adopts the hints. The unit test a_row_has_the_last_word_on_every_hinted_list_by_writing_it loads these three rows over that manifest and asserts:

silent        aliases=["the widget"] territory=["acme"] addresses=["alice@example.org"] rooms=["whatsapp/acme#1@g.us"]
says-none     aliases=[] territory=[] addresses=[] rooms=[]
says-its-own  aliases=["gizmo"] territory=["acme/plugin"] addresses=["bob@example.org"] rooms=["whatsapp/acme#2@g.us"]

Where the mail lands follows from that. The end-to-end case E2E-057 refreshes a site-level mail source over such a checkout and reads where each of eight mails is placed. Each mail carries exactly one signal: Alice or Bob as author, a link into acme/tools or acme/plugin, the word widget or gizmo, or room #1 or #2. Its hinted alias is widget, because resemblance matches whole words. Before the fix, on the spec commit, two of the rows placed mail the row had refused:

says-none:     "from-alice": ["demo by reference"], "acme-tools": ["demo by reference"],
               "acme-plugin": ["demo by reference"], "widget": ["demo by resemblance"],
               the other four unattributed
says-its-own:  "from-alice": ["demo by reference"], "from-bob": ["unattributed"]

Now says-none leaves all eight mails unattributed. Under says-its-own, Bob's mail is the project's by reference and Alice's waits in the unattributed bucket. The silent row places what it placed before.

A row whose addresses is not a list of addresses used to load without a word, because the registry schema had no addresses property and let row keys it did not know through. Now it is refused by name:

$ ephor validate --registry bad.json     # a row with "addresses": "alice@example.org"
ERROR: Registry does not match schema (1 violation):
  /projects/0/addresses: "alice@example.org" is not of type "array"

What changed

A list the row writes is final, [] included. For aliases, territory, addresses and rooms, the row's word is whether it writes the key, never whether the list is empty. A row that writes a list gets exactly that list, and nothing the checkout hints for it is added. [] refuses the hint. Only a row without the key adopts the manifest's hint. The rule holds one list at a time, so a row that refuses one hint keeps the manifest's other hints and its checks, gate and actions. A list present on the row wins at every manifest_trust level, because trust decides only whether there is a hint at all. This is the rule rooms already followed, now applied to every list a checkout can hint. (§FS-008-attribution.1.1)

In src/branches.rs, Placement's aliases and territory become Option<Vec<String>>, as rooms already was, and the new addresses joins them. All four are loaded through one helper, listed(). Placement::identity() compiles the four through one adopt, which takes the row's list when the key is present and the manifest's hint only when it is absent. The emptiness rule (if own.is_empty() { hinted } else { own }) and the bare addresses hint are gone.

The row gains an addresses field. A row's own addresses are matched as written and place a mail at the strength of a reference, exactly as hinted ones do. The registry schema declares the field as a list of non-empty strings, with [] valid. So "alice@example.org", [""] and [7] are refused at /projects/<i>/addresses, as a malformed rooms is. Read as silence, a malformed list would let the checkout's hint stand in for what the row tried to say. (§FS-008-attribution.1.1, §FS-008-attribution.3)

fallback_sources keeps [] equal to absent. No checkout can hint a source's name, so an empty list has nothing to refuse and claims nothing, as before. (§FS-008-attribution.1.1)

repos stays the project type's layout. The compiled identity's forest repositories are the type's repos[] alone, and a manifest's identity.repos is never adopted. On a registry that loads this changes nothing. Validation requires a known type on every row and a non-empty repos[] on every type, so the old emptiness rule always returned the layout. A row that wants a repository claimed beyond its forest puts it in territory, which places it as a forest repository would. (§FS-008-attribution.1.1)

The docs stop promising hints identity never read. These now name the four adopted lists:

  • docs/manual.md §4.2.1 and §4.2.2;
  • docs/registry.md, "Identity and territory";
  • the manifest schema's identity description.

They say that a manifest's identity.name, identity.ticket_patterns and identity.repos are accepted and not read as identity. Ticket prefixes come from the branches the row declares, and the forest's repositories come from its type. In the registry schema, the descriptions of aliases, territory, addresses and rooms state the presence rule, so ephor schema registry documents it.

The specification.

Who this breaks

  • A row with "aliases": [] or "territory": [] adopted the checkout's hint before and refuses it now. The JSON shape is the same; only its meaning changes. To adopt the hint again, delete the key.
  • A row with "addresses" was silently ignored before and now gets exactly what it wrote.
  • Rows without these keys, and every manifest, are unaffected. So are the attribution engine, evidence extraction and the forge request.

The version marker

The [] change keeps the JSON shape, and the release's schema diff ignores an edit to a description, so nothing in the schema would show the change. The registry schema therefore gains a version marker, "$id": "https://github.com/agent-grounds/ephor/schemas/registry/v2". The unversioned schema counts as the first version. (§FS-006-project-interface.11, §DF-006-every-hinted-list-is-read-by-presence.1)

The marker is read only by a release that follows a tag. Such a release compares every published schema from the previous tag to HEAD, and it lists a changed version marker, here an added /$id, under Compatibility notices. (§FS-002-release.1.4) ephor has no release tag yet (git ls-remote --tags origin is empty). By the same point the first release writes no notices, because nothing it ships was released before. The marker is what a release after a tag reads. From then on, the registry schema's notices are labelled v2 rather than unversioned. The new release-notices test pins this: against a tagged unversioned schema, adding the marker is noticed.

Smaller things a reviewer might trip on

  • Only identity() reads the four row fields, and Placement is never serialized. So no JSON output turns a [] into null.
  • Every path that builds a Placement from a registry goes through registry validation first. So listed() never sees a present key that is malformed.
  • On main, the identity() doc comment had drifted onto manifest(). It is moved back onto identity(), with its text unchanged.
  • Six Placement literals in tests and helpers gain addresses: None. They are in src/branches.rs, src/manifest.rs, src/capabilities.rs, src/sweep.rs, src/feed/tui/actions.rs and src/work/mod_tests.rs.
  • The feat commit e336947525 also carries the one-line edit to the manifest schema's identity description. Its message does not mention that edit. Its last sentence says the release lists the new meaning of [] under its compatibility notices. That holds only for a release that follows a tag, as explained above.
  • There is no changelog entry, on purpose. docs/changelog.md §1.1 says no change edits the file, and the release writes its line from this pull request's title.

How it was verified

The branch is spec first. c7eaf2db2b holds the specification and the failing end-to-end case and nothing else. It is followed by e336947525 (feat), 5da608c815 (docs) and 71cb37daaf (test).

  • E2E-057, "every hinted list is read by presence" (cargo test --test e2e_057_every_hinted_list_is_read_by_presence). It has four tests:

    • the silent row;
    • the row that writes [];
    • the row that writes its own lists;
    • the addresses refusal. "alice@example.org", [""] and [7] are refused at /projects/0/addresses, and [] and ["bob@example.org"] load.

    On the spec commit, 1 passed and 3 failed, each for the reason the ticket names. Now all 4 pass. The silent row passed before and after, which guards against refusing too much.

  • Unit tests

    • branches::tests::a_row_that_writes_an_empty_list_is_told_apart_from_one_silent_on_it: aliases, territory and addresses, each absent, [], and the row's own list.
    • branches::tests::a_row_has_the_last_word_on_every_hinted_list_by_writing_it: the issue's three rows.
    • branches::tests::a_typed_row_ignores_the_manifests_repositories.
    • manifest::row_tests::the_row_adopts_the_projects_hints_and_overrides_them, extended with [] refusals, a row address, and a row list winning under Trust::Ignore.
    • registry::tests::addresses_on_a_row_is_a_list_of_addresses.
  • Release notices. tests/integration/test_release_notices.py gains test_a_version_marker_given_to_an_unversioned_schema_is_noticed. It passes on the existing scripts/release_notices.py, which needed no change.

  • The local gate on 71cb37daaf. Five commands ran, 4m04s in all, and all exited 0:

    • pre-commit run --all-files, with CI's pinned grund 0.14.0 for grund check and grund fmt, plus fissile, the private-words hook and the attribution hook;
    • the Python integration suite, 102 tests, OK;
    • check_boundary.py;
    • check_parity.py;
    • the full cargo test, with TMPDIR on a symlinked directory.

    The implementation step also ran these, each passing:

    • RUSTFLAGS=-Dwarnings cargo build --all-targets --locked;
    • cargo test --all-targets --locked, 72 binaries and 1855 tests, both plain and with a symlinked TMPDIR.
  • The private-words hook passed but checked nothing, because the machine it ran on has no word list.

  • Hosted CI on 71cb37daaf. Five checks succeeded: cargo test on ubuntu-latest and macos-latest, grund check, fissile check and shipped steps (dogfood). The branch sits on main at df6d0cbad7. It is rebased before it merges.

Review

The issue's path:planned label set the route. The author approved the proposal on the issue with a reaction before anything was written. One review round approved the change at 71cb37daaf with 0 blocker, 0 major and 0 minor findings, so no fix round ran. The review confirmed each of these:

  • nothing outside identity() reads the four fields;
  • a malformed key never reaches listed();
  • the repos premise holds on every registry that loads;
  • against a test tag, the $id marker yields exactly one compatibility notice;
  • no stale rooms-only text is left.

Found and deliberately not fixed

  • R1-03 (adjacent, predates this change). docs/registry.md says identity's forest repositories come from the type's repos[] "and any repo_overrides". But declarations() never reads repo_overrides; they are merged only to render AGENTS. So an override that changes a repository's path leaves identity().repos unchanged. This needs its own ticket: either drop the clause or make declarations() apply the overrides.
  • R1-01 (nit). §AR-003-attribution.2 says rooms differ from the lists before them "only in how they match: by exact equality". Addresses match by exact equality too. What really sets rooms apart from addresses is their strength: rooms place at venue strength, addresses at reference strength. The imprecision was already on main, and this change narrowed it to "only". Commit 5da608c815's message repeats it.
  • R1-02 (nit). No assertion covers a row list under Trust::Descriptions. §FS-008-attribution.1.1 says a present list wins at every trust level, and the tests cover Full and Ignore. Descriptions takes the same code path as Full, because at that level the manifest is still read with only its commands stripped.
AI workflow: `rhei`, 12 agent invocations across 1 model; 8 tasks completed, 8 in progress.
  1. github-issues-agent-grounds-ephor-189-implement-581aa7b9.ticket supervising (visit 1) — cld, anthropic/claude-opus-5-5 — 1m50s — 1.6M in / 10.8k out
  2. github-issues-agent-grounds-ephor-189-implement-581aa7b9.ticket supervising (visit 2) — cld, anthropic/claude-opus-5-5 — 1m52s — 1.8M in / 10.9k out
  3. github-issues-agent-grounds-ephor-189-implement-581aa7b9.ticket.plan planning — cld, anthropic/claude-opus-5-5 — 8m44s — 7.1M in / 52.6k out
  4. github-issues-agent-grounds-ephor-189-implement-581aa7b9.ticket supervising (visit 3) — cld, anthropic/claude-opus-5-5 — 49.0s — 831.1k in / 5.3k out
  5. github-issues-agent-grounds-ephor-189-implement-581aa7b9.ticket supervising (visit 4) — cld, anthropic/claude-opus-5-5 — 1m31s — 1.0M in / 9.5k out
  6. github-issues-agent-grounds-ephor-189-implement-581aa7b9.ticket.specify specify — cld, anthropic/claude-opus-5-5 — 8m35s — 8.7M in / 49.7k out
  7. github-issues-agent-grounds-ephor-189-implement-581aa7b9.ticket supervising (visit 5) — cld, anthropic/claude-opus-5-5 — 3m49s — 2.0M in / 11.2k out
  8. github-issues-agent-grounds-ephor-189-implement-581aa7b9.ticket.implement implement — cld, anthropic/claude-opus-5-5 — 12m08s — 12.1M in / 53.8k out
  9. github-issues-agent-grounds-ephor-189-implement-581aa7b9.ticket supervising (visit 6) — cld, anthropic/claude-opus-5-5 — 1m20s — 737.0k in / 8.1k out
  10. github-issues-agent-grounds-ephor-189-implement-581aa7b9.ticket.review-1 review — cld, anthropic/claude-opus-5-5 — 7m48s — 9.3M in / 40.7k out
  11. github-issues-agent-grounds-ephor-189-implement-581aa7b9.ticket supervising (visit 7) — cld, anthropic/claude-opus-5-5 — 1m13s — 1.0M in / 6.8k out
  12. github-issues-agent-grounds-ephor-189-implement-581aa7b9.ticket supervising (visit 8) — cld, anthropic/claude-opus-5-5 — 1m26s — 1.2M in / 9.1k out
Accounting Value
cost $21.77
total tokens 47.6M
input tokens (incl. cache) 47.3M
input cache read 45.9M
input cache write 1.4M
output tokens (incl. cache) 268.5k
output cache read -
output cache write -
coverage Complete

…g the list

A row's word on aliases, territory, addresses and rooms is now its presence:
a list it writes is final, `[]` included, and only a row without the key
adopts what the checkout's manifest hints. The row gains an `addresses`
field, refused by name unless it is a list of non-empty strings.
`fallback_sources` keeps `[]` as absence, because no checkout can hint a
source, and forest repositories stay the type layout's
(§FS-008-attribution.1.1). DF-006 records what was decided, rejected and
deferred, and the deferred bullets of DF-003 and DF-005 point at it.

E2E-057 runs one site-level mail source against a checkout that hints all
four lists, under a row silent on them, one that writes `[]` for each, and
one that writes its own. Today the second still places Alice's mail and the
`acme` mail under the project, the third places Alice's mail and leaves
Bob's waiting, and a row whose `addresses` is `[""]` loads.
…g the list

A row's aliases, territory, addresses and rooms are now read by presence:
a list the row writes is final, `[]` included, and only a row without the
key adopts what the checkout's manifest hints (§FS-008-attribution.1.1).
Before, an empty `aliases` or `territory` adopted the hint as though the
row had said nothing, and addresses came only from the manifest, so a row
could neither replace nor refuse a claimed address.

The row gains an `addresses` field, which the registry schema declares as
a list of non-empty strings and refuses by name otherwise, as it does
`rooms`. The forest's repositories come from the project type's layout
alone and a manifest's `identity.repos` is never adopted, which on a
registry that loads was already so. The registry schema takes the `$id`
`.../schemas/registry/v2`, so the release lists the new meaning of `[]`
under its compatibility notices (§FS-006-project-interface.11).
§AR-003-attribution.2 compiles aliases, territory, addresses and rooms by
one presence rule, and rooms differ only in matching by exact equality.
The manual's §4.2.1 and §4.2.2 and the registry's "Identity and territory"
say that `[]` refuses each hint, describe the row's `addresses`, and stop
promising `identity.name`, `identity.ticket_patterns` and `identity.repos`
as adopted hints, since identity reads none of them.
The registry schema had no `$id` and now takes `.../registry/v2`. That
addition is what carries the compatibility notice for the new meaning of
an empty identity list (§FS-006-project-interface.11), so pin that the
release's schema diff notices it (§FS-002-release.1.4).
@vjovanov vjovanov changed the title spec: a registry row refuses or replaces each identity hint by writing the list feat: a registry row refuses or replaces each identity hint by writing the list Oct 8, 2026
@vjovanov
vjovanov marked this pull request as ready for review October 8, 2026 05:16
@vjovanov
vjovanov merged commit 9cd04f9 into main Oct 8, 2026
5 checks passed
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.

A registry row cannot override or refuse a checkout's identity hints: it has no addresses field, and [] refuses only rooms

1 participant