Skip to content

feat(registry): index property feed events#2326

Merged
bokelley merged 8 commits into
mainfrom
issue-2325-registry-surface
Jul 7, 2026
Merged

feat(registry): index property feed events#2326
bokelley merged 8 commits into
mainfrom
issue-2325-registry-surface

Conversation

@bokelley

@bokelley bokelley commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add RegistrySync property feed indexing by property_rid and publisher domain, including property.merged alias resolution
  • expose synchronous PropertyRegistry property lookups and an ignoredEvent hook for intentionally unindexed feed families
  • tighten registry client response/event payload aliases and generated agent profile format_ids types

Validation

  • npm run typecheck
  • npm run build
  • npm run format:check
  • node --test test/lib/registry-sync.test.js
  • npm run test:lib:fast

Closes #2325

@aao-ipr-bot

aao-ipr-bot Bot commented Jul 6, 2026

Copy link
Copy Markdown

⚠️ Argus review could not complete

The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final gh pr review). A human reviewer should take this PR.

View workflow run

This is an automated message from the Argus AI review workflow.

@bokelley bokelley force-pushed the issue-2325-registry-surface branch from 0145c5d to c030b64 Compare July 6, 2026 08:10
@aao-ipr-bot

aao-ipr-bot Bot commented Jul 6, 2026

Copy link
Copy Markdown

⚠️ Argus review could not complete

The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final gh pr review). A human reviewer should take this PR.

View workflow run

This is an automated message from the Argus AI review workflow.

@aao-ipr-bot aao-ipr-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes. The property index bootstraps from an endpoint that returns publishers, not properties — so every seeded record is a publisher wearing a fabricated property_rid, and the SDK is a witness, not a translator.

Blocking

1. Bootstrap fabricates property_rid and indexes publisher aggregates as properties. bootstrapProperties() seeds from client.listProperties()/api/properties/registry, which returns PropertyRegistryItem (src/lib/registry/types.generated.ts:2210): { domain, source, property_count, agent_count, verified }. No property_rid, no rid, no id. So propertyFromRegistryItem (sync.ts ~L1226) hits its final fallback on 100% of bootstrapped rows and mints property_rid = propertyBootstrapRid(domain) = the bare lowercased domain (~L1287). PropertyRegistry.getProperty() / getPropertiesForDomain() then hand callers a record whose property_rid is a value the registry never issued, carrying publisher-aggregate fields (property_count, agent_count) mislabeled as a property. That's fabrication at a seam — re-shaping a publisher aggregate into a property record is the same bug as injecting mock data. Second-order: two distinct properties on one domain collapse to one synthetic key, so setPropertyEntry silently overwrites — data loss. propertyRidFromPayload (~L1272) has the same failure mode on property.* events that omit property_rid and fall back to entity_id (the publisher-domain routing key). Either skip rid-less rows/events, or store bootstrap-derived rows behind a distinct surface that property_rid consumers can't mistake for a registry RID. If /api/properties/registry is genuinely aggregate-only, the bootstrap should build a publisher_domain→count view — not synthesize properties. ad-tech-protocol-expert: unsound on this path, cited spec divergence.

2. bootstrapProperties crashes when the response omits stats. totalPropertiesFromStats(response.stats) dereferences stats.total. listProperties is an unvalidated this.get(...); if the wire omits stats, this throws TypeError and aborts the entire bootstrap. It's on the first-full-page path (properties.length === limit), i.e. every registry with >200 properties — the normal case. Fix: this.totalPropertiesFromStats(this.asRecord(response.stats)). code-reviewer: High.

Things I checked

  • Changeset present (.changeset/registry-property-sync.md), minor, matches the impact — new exports (RegistrySyncProperty, ListBrandsResponse, ValidateAdagentsResponse, ...) and new methods (getProperty, getPropertiesForDomain), all additive. Correct type.
  • types.generated.ts was regenerated via the matching scripts/generate-registry-types.ts template edit, not hand-edited — generator and output in sync. format_ids?: unknown[]string[] and AuthorizationEntry = AuthorizationEventPayload & { effective_to? } are both real narrowings.
  • Event taxonomy is spec-correct: authorization.modified and property.merged with {alias_rid, canonical_rid} are genuine RegistryFeedEvent members; collection.*/publisher.* no-op through the ignoredEvent hook is defensible (not yet consumed).
  • resolvePropertyRid alias-cycle handling is safe (seen set); handlePropertyMerge guards aliasRid === canonicalRid. cloneJsonValue is correct for JSON data and defends internal state from caller mutation.
  • The runtime guards (isAuthorizationPayload, isAgentInventoryProfile, isAgentCompliance) replacing the old payload as unknown as T casts are the right direction — fail-closed on malformed payloads beats trusting a cast.

Follow-ups (non-blocking — file as issues)

  • Fabricated authorization id. authorizationRowIdFromEntityId (~L1121) derives an id from event.entity_id when the payload lacks one, then stores and surfaces it via the authorization getters — callers see an id the registry never sent. It also drives dedup: a composite entity_id that doesn't contain both agent_url and domain substrings gets misclassified as a real row id.
  • O(total-authorizations) rescan per auth event. removeAuthorizationEntries iterates and rebuilds every authByDomain/authByAgent bucket on each authorization.* event, versus the old targeted domain+agent lookup. Throughput regression on a busy feed; the grant/modify path knows agent_url+publisher_domain and could target those buckets except in the id-only revoke case.
  • Stale placeholder on domain-less real events. upsertPropertyEvent (~L1158) only deletes the domain-keyed bootstrap placeholder when next.publisher_domain is set; a changed-fields-only property.updated carrying the real RID but no domain leaves the placeholder indexed, so getPropertiesForDomain returns a duplicate.
  • No max-iteration guard in the pagination loop (~L544): a server that always returns full pages with no stats.total loops unbounded.
  • effective_to is never mapped to effective_until — a legacy deployment sending only effective_to leaves the spec field blank for consumers.

Minor nits (non-blocking)

  1. isAuthorizationPayload accepts empty strings. typeof '' === 'string' passes for agent_url/publisher_domain (~L1430); the old granted guard rejected them. Could bucket an entry under an empty domain key.

Fix the two blockers — the fabricated bootstrap RID especially, since it's the primary surface this PR ships — and I'll re-review. The guard-hardening and merge/alias machinery are the right shape.

@aao-ipr-bot aao-ipr-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One blocking issue: the authorization-revoke rework introduces a fail-open regression. Everything else — the property-feed indexer, alias resolution, witness-not-translator discipline — is clean and well-tested. Fix the revoke matching and this ships.

Blocking

authorizationMatches id-only match silently drops a revoke — fail-open regression. src/lib/registry/sync.ts (new authorizationMatches, cited by both code-reviewer and security-reviewer at the diff's L610 / reported L1142):

if (target.id && options?.matchByRowId) return entry.id === target.id;

Pre-PR, authorization.revoked matched by agent_url + publisher_domain + (optional) authorization_type and ignored row id. This PR replaces that with id-only equality whenever the revoke carries an id. Concrete sequence:

  1. authorization.granted arrives without id (the grant path never requires one; schema marks id as "present when backed by a materialized effective authorization row" — so non-materialized grants legitimately lack it). Entry stored; authLocationsById not populated.
  2. authorization.revoked later arrives with id (plus the agent_url+publisher_domain the spec guarantees). matchByRowId = Boolean(entry.id || !entry.authorization_type)true.
  3. removeAuthorizationEntries looks up authLocationsById.get(id) → miss. It scans the target's own agent/domain buckets, which do contain the no-id grant — but authorizationMatches returns undefined === 'auth-row-1'false. Not removed.

Result: isAuthorized(agent, domain) returns true for a revoked authorization. The class JSDoc sanctions isAuthorized() for enforcement (gated on state/lag), so this is a fail-open decision for the consumer. The old tuple match would have purged it — this is a regression, not a pre-existing gap. security-reviewer flags the same root cause producing a duplicate/ghost entry on authorization.modified (grant-no-id + modify-with-id appends a second row). Both resolve with one change: when target.id && matchByRowId finds no id match, fall back to the agent_url + publisher_domain (+ type) tuple comparison instead of returning early. Add a test for grant-without-id → revoke-with-id (the existing revoke test at test/lib/registry-sync.test.js:611 only covers no-id/no-id).

fail-closed beats fail-open.

Things I checked

  • Property indexer holds witness-not-translator. propertyFromPayload copies wire fields verbatim through cloneJsonValue and invents nothing; the does not synthesize a domain-keyed placeholder and does not bootstrap aggregate property registry rows tests confirm properties are populated only from feed events, never coerced from the /api/properties/registry aggregate. ad-tech-protocol-expert: sound-with-caveats — event families (property.*, authorization.*) and payload fields match the registry OpenAPI exactly.
  • Alias-cycle guard is correct. resolvePropertyRid's seen set breaks A→B→A; verified against the merge tests.
  • Deep-clone isolation works. property.created indexes... mutates a returned object and re-reads to prove the index is unshared. Right shape.
  • authorization_type optional is more spec-correct, not less — AuthorizationEventPayload (types.generated.ts:3437) documents revoked events omit it. The old required-field type was lying.
  • Changeset present (.changeset/registry-property-sync.md, minor) — correct category for new methods + new exports + new event.
  • format_ids: string[] narrowing is spec-accurate on the event path (AgentEventPayload.format_ids is string[]).

Follow-ups (non-blocking — file as issues)

  • Un-normalized auth index keys. authByDomain/authByAgent key on raw publisher_domain/agent_url while every other index routes through normalizeDomainKey (sync.ts:1121). A grant on Example.com + revoke on example.com land in different buckets and the revoke no-ops — same fail-open class as the blocker. Schema even ships agent_url_canonical for this reason. (security-reviewer, LOW/MEDIUM.)
  • agent.removed bypasses removeAuthorizationEntries. Pre-existing, but this PR's new authLocationsById compounds it: agent.removed calls authByAgent.delete(id) directly, leaving authByDomain rows (stale isAuthorized) and now leaking authLocationsById entries. Route it through the new remover. (code-reviewer.)
  • isAgentCompliance enum drift. Hardcodes status ∈ {passing,degraded,failing,unknown} but the schema documents opted_out (types.generated.ts:1204). A conforming compliance_summary with opted_out is silently dropped from the index while compliance_changed still emits — index and event disagree. Route guard-rejected-but-valid payloads through ignoreEvent() so the drop is observable, or derive the set from the schema.
  • effective_to → effective_until is the one translator behavior in the diff. normalizeAuthorizationEntry synthesizes effective_until from a legacy alias that isn't in the HEAD schema. Non-destructive (both preserved), but confirm a real deployment emits effective_to — otherwise it's dead code dressed as a compat shim. (ad-tech-protocol-expert + code-reviewer.)

Minor nits (non-blocking)

  1. Changeset text understates the narrowings. format_ids: unknown[] → string[] and the tightened listBrands/getBrandJson/validateAdagents return types are compile-visible to TS consumers. One line in the changeset noting the tightening would be honest.

Fix the revoke matching and re-request review.

Comment thread src/lib/registry/sync.ts Fixed
Comment thread test/lib/registry-sync.test.js Fixed
@bokelley bokelley force-pushed the issue-2325-registry-surface branch from c4cf714 to c3681ee Compare July 7, 2026 04:01
@aao-ipr-bot

aao-ipr-bot Bot commented Jul 7, 2026

Copy link
Copy Markdown

⚠️ Argus review could not complete

The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final gh pr review). A human reviewer should take this PR.

View workflow run

This is an automated message from the Argus AI review workflow.

@aao-ipr-bot aao-ipr-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One blocking fail-open in the authorization revoke path, plus a changeset that understates the wire impact. The property index itself is the right shape — RID-keyed, alias-resolved, feed-authoritative — and correctly refuses to fabricate RIDs from the aggregate /api/properties/registry rollup.

MUST FIX

1. Revoke fails to purge a canonicalized grant — fail-open on the authorization witness. src/lib/registry/sync.ts, authorizationMatches (authAgentKey(entry) !== authAgentKey(target)), reached from case 'authorization.revoked'.

The schema is decisive (types.generated.ts:3432-3437): agent_url_canonical is the "Registry-canonicalized form of agent_url for equality checks," and authorization.revoked "carries only agent_url + publisher_domain." So:

  • authorization.granted arrives with agent_url_canonical. upsertAuthorizationEntry keys the entry by authAgentKey(entry), which prefers agent_url_canonical.
  • authorization.revoked arrives with no agent_url_canonical and no id. The tuple match computes authAgentKey(target) = authAgentKeyFromUrl(agent_url) — scheme+authority lowercased, path preserved.
  • The registry's canonical form exists precisely because it differs from naive normalization (trailing slash, default port, path resolution — otherwise the field is dead weight). So authAgentKey(storedEntry) (canonical) !== authAgentKey(revokeTarget) (naive), authorizationMatches returns false, and the granted row is never removed.

What breaks for adopters: on any registry that populates agent_url_canonical on grants, a de-authorized agent stays live in the index — getAuthorizationsForDomain / getAuthorizationsForAgent keep listing it and isAuthorized keeps returning true. That is fail-open on the exact path whose own docstring warns against fail-open. security-reviewer: High, confirmed against the schema contract. The new revoke tests all use payloads with matching keys, so they miss it.

Fix (any one): index grants under both authAgentKey(entry) and authAgentKeyFromUrl(entry.agent_url); or in authorizationMatches, when the target has no canonical, match on entry.agent_url_canonical OR authAgentKeyFromUrl(entry.agent_url). Add a regression test: grant with agent_url_canonical differing from agent_url, revoke with only agent_url + publisher_domain, assert the domain bucket is empty.

2. Changeset is minor but the diff ships TS-breaking public type narrowing. .changeset/registry-property-sync.md:2. AuthorizationEntry.authorization_type goes required→optional (the hand-written type had authorization_type: string; it now flows from AuthorizationEventPayload where it's ?), and three public return types narrow from Record<string, unknown>: listBrandsListBrandsResponse, getBrandJsonGetBrandJsonResponse | null, validateAdagentsValidateAdagentsResponse (src/lib/registry/index.ts:492,504,1009), plus format_ids unknown[]string[]. Consumers doing entry.authorization_type.x or reading an off-type key off the old Record return fail tsc after this ships. Per the repo's own semver table that's major. Bump it, or justify in the PR body why these are treated as non-breaking. code-reviewer flagged the same.

Things I checked

  • Property index is sound. resolvePropertyRid has a cycle guard; handlePropertyMerge dedups the alias out of its domain set and re-points via the alias map; setPropertyEntry removes the RID from the prior domain on a domain change; returns are deep-cloned. No stale-entry or double-listing path.
  • No aggregate fabrication. Bootstrap does not ingest /api/properties/registry rollups (test asserts propertiesCallCount === 0) — those rows carry no property_rid, so indexing them would mean synthesizing RIDs. Correct call. ad-tech-protocol-expert: sound.
  • property.merged alias→canonical matches the wire contract (alias_rid merged away, canonical_rid survives). Dropping the alias row and re-keying is the right realization.
  • agent.removed now purges authByDomain too, fixing a prior leak where authByAgent.delete() left domain-side authorizations stranded. Good catch.
  • effective_toeffective_until preserves the original (normalizeAuthorizationEntry) rather than rewriting it — documented legacy alias, not fabrication.

Follow-ups (non-blocking — file as issues)

  • isAgentInventoryProfile gates the whole profile on format_ids. A single non-string element in format_ids discards the entire inventory_profile update — existing agents retain stale inventory, new agents get emptyInventoryProfile(). That's silent lossiness against "return exactly what agents provide." Don't let one non-critical field nuke the object.
  • isAgentCompliance drops compliance_summary on any out-of-enum status/lifecycle_stage. When the registry adds a new status, the summary is discarded (stale retained) even though compliance_changed still fires with the raw value — an inconsistent, silently-lossy state.
  • isAuthorized lookup uses weaker canonicalization than the index key. It probes with authAgentKeyFromUrl while entries are stored under registry canonical — fail-closed, but worth a doc note on the public method. (Same root cause as MUST FIX #1, from the read side.)
  • Properties are feed-tail-only — a property created before the retention window that never re-emits won't be indexed. Consistent with the existing authorization design; document it alongside the staleness note.

Minor nits (non-blocking)

  1. propertyDomainFromPayload reaches into the opaque property blob for publisher_domain. Defensible for index keying, but it trusts a field inside a shape the spec doesn't guarantee. Worth a comment noting the fallback is best-effort.

Fix the revoke matching and re-cut the changeset. The index design and merge handling are the right shape — this is the auth path, not the property work, that needs another pass.

@aao-ipr-bot

aao-ipr-bot Bot commented Jul 7, 2026

Copy link
Copy Markdown

⚠️ Argus review could not complete

The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final gh pr review). A human reviewer should take this PR.

View workflow run

This is an automated message from the Argus AI review workflow.

@aao-ipr-bot aao-ipr-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking on one thing: the reworked authorization indexing has a revocation false-allow on the enforcement path. RegistrySync.isAuthorized() is documented as the gate for authorization decisions, so a revoked agent that keeps reading as authorized is a correctness bug, not a trust-model caveat. Everything else — the property-feed indexing, the witness discipline, the event-name coverage — is right.

MUST FIX

Revoked authorization survives because grant and revoke normalize the agent key differently. src/lib/registry/sync.ts, authAgentKey / authorizationMatches / the authorization.revoked handler.

The schema is explicit at schemas/registry/registry.yaml:3005: "Present on authorization.granted; authorization.revoked carries only agent_url + publisher_domain." So a revoke carries no id, no authorization_type, and no agent_url_canonical. Grants carry agent_url_canonical "for equality checks" (registry.yaml:2999) — a form whose entire purpose is to differ from the raw agent_url.

Trace it:

  1. Grant agent_url: https://ads.example.com/agent-card, agent_url_canonical: https://ads.example.com → bucketed under authAgentKey(entry) = https://ads.example.com.
  2. Revoke arrives with agent_url: https://ads.example.com/agent-card only. authorization.revoked computes matchByRowId: Boolean(entry.id || !entry.authorization_type) = true, but with no id the row-id branch is skipped and it falls to the tuple match. The domain bucket still holds the grant, so authorizationMatches(grantEntry, revokeTarget) runs and compares authAgentKey(entry) (https://ads.example.com, canonical) against authAgentKey(target) = authAgentKeyFromUrl(agent_url) (https://ads.example.com/agent-card, path preserved). They differ → returns false → the entry is removed from neither index.
  3. isAuthorized('https://ads.example.com', 'example.com') still returns true.

One sentence for adopters: a benign publisher revocation never lands, so isAuthorized() and getAuthorizationsForDomain() keep reporting a de-authorized agent as authorized until the next full re-bootstrap. This is a regression — pre-PR revoke matched on raw e.agent_url === agentUrl, symmetric with the grant because both event families always carry agent_url.

security-reviewer: HIGH, revocation-survival false-allow. code-reviewer: same finding as an issue-to-fix. Both converged independently.

Fix direction: key and match on a form every event family carries. Make authAgentKeyFromUrl implement the registry's own canonicalization (lowercase scheme+host, strip default port, normalize dot-segments) and have authAgentKey derive from agent_url — keeping agent_url_canonical as stored metadata / tie-breaker applied symmetrically on the query side. That closes the false-allow and the matching query-side false-deny (below) in one move. Add a test where the revoke carries only agent_url + publisher_domain and the grant's canonical strips a path — the current 'authorization indexes use canonical agent urls' test feeds the revoke a canonical the wire never sends, so it verifies a shape that can't occur.

Things I checked

  • Property indexing is a faithful witness. propertyFromPayload copies payload keys verbatim through cloneJsonValue; the only derived value is property_rid resolving to the canonical RID after property.merged, which is legitimate alias resolution of a real registry value. Confirmed the SDK does not seed properties from the aggregate /api/properties/registry endpoint — the test at test/lib/registry-sync.test.js:1207 asserts propertiesCallCount === 0. Aggregate rows are publisher-shaped, not RID records; treating them as feed properties would fabricate RIDs. Right call.
  • resolvePropertyRid bounds the alias chain with a seen set — no infinite loop on a cyclic A→B, B→A merge pair.
  • setPropertyEntry re-keys propertyRidsByDomain when a property changes publisher_domain, and prunes the empty domain set. Correct.
  • Event-name coverage verified against schemas/registry/registry.yaml: property.created/updated/reactivated/stale/merged, authorization.modified, and the collection.*/publisher.* families routed to ignoreEvent() are all real enum members. No invented types. alias_rid/canonical_rid/property_rid match PropertyEventPayload.
  • Changeset present (.changeset/registry-property-sync.md, minor) — correct for additive API surface (RegistrySyncProperty, ListBrandsResponse, GetBrandJsonResponse, ValidateAdagentsResponse, the property getters). types.generated.ts is a genuine regen (timestamp + format_ids: string[]), not a hand edit.

Follow-ups (non-blocking — file as issues)

  • effective_to is not in the spec. ad-tech-protocol-expert: effective_to has zero occurrences in schemas/; normalizeAuthorizationEntry synthesizes the canonical effective_until from it. AuthorizationEventPayload is additionalProperties: {} so a deployment could emit it, but the "legacy alias still returned by some registry deployments" comment is an unverified claim. Either cite the deployment/changelog that shipped effective_to, or drop the & { effective_to? } extension and the normalization — right now it's speculative code writing a canonical field's value.
  • Query-side false-deny (Medium). Same root cause: isAuthorized/getAuthorizationsForAgent derive the key with authAgentKeyFromUrl (canonical-blind) while entries are stored under agent_url_canonical, so a caller passing the agent's advertised URL misses a live authorization. Fails closed, so not a bypass — the MUST-FIX resolution covers it.
  • No property delete path. property.stale is treated as an upsert and there's no property.removed/deleted case, so stale properties and every propertyAliasesByRid entry accumulate until reset(). Bounded by registry record count, consistent with the class-doc replica caveat — worth a follow-up if property.stale should evict.

Minor nits (non-blocking)

  1. authAgentKeyFromUrl folds userinfo case and leaves scheme-less URLs unnormalized. https://User:Pass@host lowercases the credential; a URL with no :// is returned verbatim so a mixed-case bare host won't unify. No impact for well-formed absolute agent origins — worth a comment.
  2. Dead test mocks. The /api/properties/registry shims at test/lib/registry-feed-stream.test.js:176,755 and the 404-fallback in test/lib/registry-sync.test.js are leftovers from the earlier bootstrap approach; never exercised now.

Fix the revocation asymmetry and add the schema-accurate revoke test, and this is ready.

@aao-ipr-bot aao-ipr-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right shape overall — the property index is a faithful witness (only feed-supplied property_rids land, aggregate-list bootstrap was correctly dropped) and the authorization refactor is well-tested. Two findings on the authorization path keep me from a clean approve; neither is a hard block, both want your explicit call.

Things I checked

  • Witness invariant holds. ad-tech-protocol-expert: sound. upsertPropertyEvent/propertyRidFromPayload (sync.ts:1268-1364) bail to null unless the payload carries a real property_rid/rid/id; property handlers take payload only, never event.entity_id, so the deleted domain→RID synthesis can't sneak back in. propertyDomainFromPayload reads publisher_domain only from the payload. No fabricated fields, no placeholder substitution.
  • Event-type coverage is spec-exact. property.created/updated/reactivated/stale/merged, authorization.granted/modified/revoked, and the new explicit ignoreEvent branches for collection.* / publisher.adagents_* all match the generated CatalogEvent union (types.generated.ts:3160-3313). authorization.modified is a real event type. Type-less revoke → tuple removal; id-bearing revoke → row-id removal, consistent with the schema's "revoked carries only agent_url + publisher_domain" note.
  • Changeset is major and correct. format_ids?: unknown[]string[] and the AuthorizationEntry retype are compile-breaking for TS consumers; major is the right bump. Generated-file edit tracks the scripts/generate-registry-types.ts transform — regenerated output, not a hand-edit.
  • Property alias/merge is cycle-safe. resolvePropertyRid has a seen guard, handlePropertyMerge early-returns on aliasRid === canonicalRid, and propertyRidsByDomain only ever stores canonical RIDs. Domain reindex on publisher_domain change is handled in setPropertyEntry.

Open questions (what would flip me to approve)

1. Agent-URL identity is host-level, and path is never load-bearing — even when a path-bearing agent_url_canonical is present. authAgentKeysForUrl (sync.ts:1234) always adds both the full URL and the origin-only key from authAgentKeyFromUrl (${protocol}//${host}, path stripped, sync.ts:1249-1273), and authorizationAgentMatches succeeds on any set overlap. So isAuthorized('https://ads.streamhaus.example.com/agent-card', 'nytimes.com') returns true off a grant for the bare origin — which your own test asserts (registry-sync.test.js, the /agent-card and :443/other/../agent-card cases). security-reviewer: Medium. The prior code was exact-string; this is a strict widening in the security-sensitive direction. Concrete failure: a shared multi-tenant agent host with path-based tenancy (https://sales.saas.com/tenant-a/mcp vs .../tenant-b/mcp) — an adopter gating on isAuthorized treats tenant-b as authorized off tenant-a's grant.

I read this as intentional (the test is deliberate, and same-agent-referenced-by-agent-card-path is a real interop need). Graded Medium because it needs both path-based multi-tenant hosting and an adopter gating on an advisory, staleness-warned helper. Two things would flip me: (a) confirm origin-level agent identity is the intended contract, and (b) either honor a path-bearing agent_url_canonical verbatim as the primary key (only collapse to origin when neither side carries a path), or add one line to the isAuthorized docstring stating identity is origin-scoped so path-based tenancy is not distinguished. Right now the code silently overrides the registry's stated equality key.

2. Idless-then-id grant of the same authorization double-counts and can survive a revoke. code-reviewer, Issue (not Blocker): the grant path calls upsertAuthorizationEntry with no fallbackToTupleOnMissingRowId, so an id-bearing grant dedups by row-id only. Sequence — authorization.granted{agent,pub,type} (no id) → bucket [A]; then the materialized authorization.granted{id:'row-1',agent,pub,type}authorizationMatches(A,B,{matchByRowId:true}) compares undefined === 'row-1' → false, so A survives. Result: getStats().authorizations counts 2, getAuthorizationsForDomain returns the dupe, and a later authorization.revoked{id:'row-1'} removes only B — phantom A keeps isAuthorized true after revocation. This is a realistic ordering given the schema's own framing (id? present only once the grant is backed by a materialized row). Fix is one flag: pass fallbackToTupleOnMissingRowId: true on the grant path too, or tuple-dedup on grant in addition to row-id.

Follow-ups (non-blocking)

  1. LLM-safety doc on property getters. getProperty/getPropertiesForDomain return registry-influenced strings (reason, evidence, classification, identifiers) through the open [key: string]: unknown shape. RegistryClient.getBrandJson already carries a "sanitize before LLM prompts" caution; mirror it on the property surface.
  2. __proto__ in computed assignment. propertyFromPayload/cloneJsonValue do obj[key] = ... over Object.entries of a JSON.parsed payload. Impact is confined to the per-property object (not global prototype pollution), but skipping __proto__/constructor or using Object.create(null) is cheap insurance.
  3. cloneJsonValue assumes acyclic input (sync.ts:1560) — safe for JSON feed payloads; a one-line comment noting the assumption is enough.

Findings 1 and 2 both sit on the authorization path and both were flagged independently by the security and code reviewers, so I'd rather see them consciously resolved than merged on assumption. Confirm the origin-identity contract and address the double-count, and this is an approve.

@aao-ipr-bot aao-ipr-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean, well-tested addition — property feed indexing, an onIgnoredEvent hook, and the authorization rework all land as replica-of-truth code. The design principle holds: properties arrive only from property.* feed events (no aggregate bootstrap), payload fields are copied verbatim, and returned objects are cloned. Witness, not translator.

Things I checked

  • No property bootstrap. bootstrap() (sync.ts:425-449) paginates searchAgents and drains the feed only — properties are seeded exclusively from property.* events. Confirmed by the "does not bootstrap aggregate property registry rows" test, which asserts propertiesCallCount === 0. Right shape: no synthetic domain-keyed stubs standing in for real property_rid records.
  • Changeset present and correctly typed. .changeset/registry-property-sync.md is major — justified by the public response-type tightening (listBrandsListBrandsResponse, getBrandJson, validateAdagents, format_ids: unknown[]string[], and the AuthorizationEntry reshape onto the generated AuthorizationEventPayload). Type impact matches the bump.
  • getStats().authorizations is still accurate. It sums authByDomain (sync.ts:395-396), where each entry is pushed exactly once. The new multi-key duplication lives in authByAgent, which the counter never touches. No double-count.
  • effective_toeffective_until mapping is real and tested. normalizeAuthorizationEntry (diff L643-646) copies the legacy alias into effective_until; the "maps legacy effective_to to effective_until" test asserts both fields resolve. (ad-tech-protocol-expert claimed this mapping "is a comment, not behavior" — it reviewed a stale read; the mapping is on the wire.)
  • Property merge is cycle-safe. resolvePropertyRid follows the alias chain with a seen guard (diff L911-919); handlePropertyMerge resolves both sides before aliasing and bails when aliasRid === canonicalRid (diff L816-842). Domain reindexing on merge/update is covered by the alias-cleanup tests.
  • Event vocabulary matches the registry schema. property.created/updated/reactivated/stale/merged, collection.*, publisher.adagents_*, and authorization.granted/revoked/modified all match types.generated.ts. Post-PR nothing is silently dropped — the default case and the named ignore cases route through ignoreEvent, which fires the callback and emits ignoredEvent. Real improvement over the prior silent no-op.

Open question (non-blocking — flagging, not holding)

Agent identity in isAuthorized collapses to protocol//host. authAgentKeyFromUrl (sync.ts:781-803) drops path/query/fragment, and authAgentKeys puts that host key into the match set alongside the registry-supplied agent_url_canonical. So https://host/tenant-a and https://host/tenant-b share the key https://host and cross-match — security-reviewer walked a concrete multi-tenant-single-host confusion where an unauthorized co-hosted agent reads as authorized (Medium; not exploitable in the common one-host-per-agent shape). The host key also broadens revocation, since revokes omit authorization_type and match-all on the tuple.

This is intentional and tested (the .../agents/../agent-card case), and isAuthorized self-documents as advisory — "For decisions where staleness is unsafe (e.g. authorization enforcement)... rather than trusting a lookup blindly" (sync.ts:146-151) — which is why it isn't a block. But the collapse overrides the finer identity the registry hands you in agent_url_canonical. Worth a decision: when agent_url_canonical is present, is host-level matching still what you want, or should the canonical value win and the fallback preserve the full path? Not blocking; file as a follow-up if the host model is deliberate.

Follow-ups (non-blocking — file as issues)

  • Authorization getters return live internal references while properties/brand now clone. getAuthorizationsForDomain/getAuthorizationsForAgent (sync.ts:377-391 in diff) hand back the internal arrays and entry objects; getProperty/getPropertiesForDomain/getBrandHierarchy all clone. The PR's own selling point is caller-mutation safety — the security-sensitive surface is the one spot left exposing references. Pre-existing, but this PR makes the inconsistency conspicuous. Cloning here would corrupt-proof getStats().authorizations too.
  • agent_url_canonical is unvalidated. isAuthorizationPayload (diff L972-988) validates every field except agent_url_canonical, which authAgentKeys then .trim()s (diff L760). A non-string value from a self-hosted/older feed throws inside applyEvent before the cursor advances — a hot re-fetch loop. Add the stringValue check alongside the others.
  • Fallback URL normalization keeps userinfo. When new URL() throws, the fallback (sync.ts:792-802) slices authority on / ? # only — it doesn't cut @, so user:pass@host keys differently from the parsed branch. Fail-closed (under-matches), but inconsistent with the primary path.
  • property.stale is upserted as a live record. A stale property stays returned by getProperty/getPropertiesForDomain with no status surfaced through the lookup API; callers must inspect raw reason/last_resolved_at. Fine for v1 (fields are preserved verbatim), but worth deciding whether stale should flag or filter.

One aside: an expert pass flagged a listProperties/PropertyRegistryItem bootstrap and "dead RID fallbacks" — none of that is in this diff. Reviewing against a branch that isn't the PR is its own kind of drift. The code here is cleaner than that read suggested.

Approving on the strength of the feed-only property sourcing plus the cycle-safe merge and the dedicated auth-rework test coverage. Follow-ups noted above.

@bokelley bokelley merged commit 8657c28 into main Jul 7, 2026
31 checks passed
@bokelley bokelley deleted the issue-2325-registry-surface branch July 7, 2026 19:53
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.

Type the rest of the registry surface: consume property/collection/publisher events in RegistrySync + ref existing schemas for opaque sub-payloads

2 participants