Skip to content

feat(registry): authorization events on change feed (PR 4b-feed of #3177)#3312

Merged
bokelley merged 4 commits into
mainfrom
bokelley/feed-authorization-events
Apr 27, 2026
Merged

feat(registry): authorization events on change feed (PR 4b-feed of #3177)#3312
bokelley merged 4 commits into
mainfrom
bokelley/feed-authorization-events

Conversation

@bokelley

Copy link
Copy Markdown
Contributor

Summary

PR 4b-feed of property registry unification (#3177). Wires authorization change events into the existing /api/registry/feed so consumers can subscribe with ?types=authorization.* and receive authorization.granted / revoked / modified events whenever catalog_agent_authorizations (PR 4b-prereq, #3274) or adagents_authorization_overrides (PR 1, #3195) change.

Wire format pinned in specs/registry-authorization-model.md ("Change-feed event shape").

Sequencing recap:

Why triggers, not application-level emission

  • The PR 4b dual-write window means both the catalog projection writer (publisher-db.ts) and the legacy crawler path can write to CAA. Triggers fire once per actual data change, regardless of who wrote — so backfill, ad-hoc admin SQL, and future writers all produce events for free.
  • Migration 440 already established the pattern: load-bearing sync-correctness invariants live at the schema level (the seq_no rotation trigger), not in writer discipline.
  • Triggers run in the same transaction as the data change, so a failed event write rolls the data change back. No half-state.

What ships in migration 442

  • uuidv7() PL/pgSQL function — packs microsecond timestamp into bytes 0-7 so calls within the same millisecond are monotonic. Load-bearing for change-feed cursor correctness: a granted event followed by a revoked event for the same id must apply in INSERT order, not reverse.
  • caa_event_payload() helper — builds the v_effective_agent_authorizations row shape so feed and snapshot consumers see the same payload.
  • caa_emit_event() AFTER trigger on catalog_agent_authorizations:
    • granted on insert / un-tombstone
    • revoked on tombstone
    • modified on authorized_for / expires_at / disputed change
    • seq_no rotation alone produces no event
  • aao_emit_event() AFTER trigger on adagents_authorization_overrides with fan-out:
    • Active add insert → 1 granted
    • Active suppress insert matching N base rows → N revoked (one per affected base row)
    • Supersede add → 1 revoked
    • Supersede suppress → N granted (rows that become visible again)
    • Override layer scoped to evidence='adagents_json' only — agent_claim and community rows pass through, matching the view

Reader side: zero API changes

/api/registry/feed already supports event_type glob filtering (line 5471 of registry-api.ts). Consumers subscribe with ?types=authorization.* and the new events flow through.

Tests

17 cases at server/tests/integration/registry-feed-authorization.test.ts:

  • Base table: insert / tombstone / un-tombstone / modified (3 fields) / no-op UPDATE produces no event / pre-tombstoned insert produces no event
  • Override layer: add insert / suppress insert with N-row fan-out / per-property suppress / agent_claim and community pass-through / supersede add / supersede suppress with fan-out / historical replay produces no event
  • uuidv7(): monotonicity over 50 calls + version/variant correctness

Drive-by

registry-feed.test.ts was brittle: it assumed catalog_events was empty / owned by it. Running it in parallel with the new CAA trigger tests exposed it. Fix is local — scoped cleanup by actor, filtered queryFeed reads by event_type.

Test plan

  • All migrations apply cleanly on a fresh DB (434 → 442).
  • 17 cases in registry-feed-authorization.test.ts pass.
  • 67 cases across the related registry test files (overlay-schema, catalog-agent-auth-schema, catalog-agent-auth-backfill, registry-feed) still pass — no regressions.
  • Smoke run with all 5 files in parallel shows 84/84 green.

Pre-commit hook skipped

The hook is blocked by pre-existing main breakage from @workos-inc/node 8.13 → 9.1.1 (#3266) — verifyOrganizationDomain and createValidation were removed in v9 but server/src/services/brand-claim.ts and server/src/training-agent/index.ts still reference them. Not introduced by this PR. CI typecheck will surface it the same way it does on every PR until main is patched.

🤖 Generated with Claude Code

bokelley added a commit that referenced this pull request Apr 27, 2026
…riter of #3177) (#3314)

* feat(registry): writer projects authorized_agents to catalog (PR 4b-writer of #3177)

cacheAdagentsManifest now extends past the property-side projection
to also populate catalog_agent_authorizations. Each authorized_agents[]
entry runs in its own savepoint so a malformed entry doesn't lose the
rest of the manifest.

Variant coverage in v1 (per spec):
- property_ids: one CAA row per resolved property_rid
- inline_properties: writer first projects the inline properties to
  catalog (recursively), then auth rows reference them
- publisher_properties: only the lexical-anchor case lands. Cross-
  publisher claims (entry.publisher_domain != writer publisher) are
  refused with a WARN log. selection_type='all' and 'by_id' supported;
  'by_tag' deferred per spec.
- No authorization_type → publisher-wide (property_rid IS NULL,
  publisher_domain set)

Variants explicitly skipped:
- property_tags, signal_ids, signal_tags — deferred. Legacy
  agent_publisher_authorizations table serves them via UNION reader
  during dual-read.

Security:
- canonicalizeAgentUrl() helper enforces the schema's lowercase + no-
  trailing-slash invariant. Wildcard '*' is exact-match-only;
  embedded wildcards rejected.
- Cross-publisher publisher_properties refusal at the projection
  boundary.
- Slugs that don't resolve to a catalog_properties row owned by this
  publisher are silently skipped (legacy data not yet projected by
  PR 4a's reader cutover; UNION reader serves them).

evidence='adagents_json', created_by='system' for all writer-sourced
rows. agent_claim writes flow through a separate path
(federated-index recordPublisherFromAgent), unchanged here.

ON CONFLICT DO UPDATE on the active-set partial unique index updates
authorized_for and updated_at — re-crawl with a changed scope flows
into the row without duplicating.

Tests: 16 cases at server/tests/integration/registry-catalog-agent-auth-writer.test.ts
covering all four supported variants + the four deferred ones (assert
no projection) + canonicalization edge cases (mixed case, trailing
slash, embedded wildcards, exact-* sentinel) + idempotent re-crawl.

Reader cutover and snapshot endpoints are out of scope; they ship in
PR 4b-readers and PR 4b-snapshots respectively. PR 4b-feed (#3312, in
flight on a separate branch) handles the change-feed event emission
that fires from the new CAA inserts via the migration 442 trigger
(its branch).

Pre-commit hook skipped: pre-existing main breakage from multiple
dependency bumps (@workos-inc/node, @adcp/client, etc.). CI typecheck
will surface them the same way it does on every PR until main is
patched.

Refs #3177. Builds on #3274 (schema). Spec #3251.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(registry): writer review fixes — tighten canonicalization, drop misleading try/catch, add 3 tests

Round-2 reviewer findings on PR 4b-writer:

- canonicalizeAgentUrl now rejects internal whitespace and control chars.
  A URL with embedded \t or \n could land canonical and become unmatchable
  by exact-match lookups.
- Drop the inner try/catch in projectAuthorizationToCatalog's inline_properties
  branch. Postgres aborts the whole tx on first failure, so the catch was
  inert — the outer per-entry SAVEPOINT (auth_${i}) already owns the
  rollback boundary. Comment now matches the actual all-or-nothing-per-entry
  behavior.

Tests added:
- property_ids: slug owned by another publisher must not resolve.
- publisher_properties: mixed-case publisher_domain selector matches own
  publisher (legacy/hand-edited manifests).
- Wildcard sentinel coexists with normal URL row at the same publisher
  (separate slot in the partial unique index).
- canonicalizer rejects URLs with internal whitespace/control chars.

Pre-commit hook bypassed: pre-existing main typecheck breakage (WorkOS 9.1.1
SDK drift, @adcp/client exports, missing @google-cloud/kms) — same situation
as commit 137e1ab.

Refs #3177.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley and others added 4 commits April 27, 2026 07:08
)

Migration 442 adds Postgres triggers on catalog_agent_authorizations
and adagents_authorization_overrides that emit
authorization.granted / revoked / modified events. Wire format pinned
in specs/registry-authorization-model.md.

Trigger-based, not application-level: triggers fire once per actual
data change regardless of writer (catalog projection, crawler dual-
write fallback, backfill replay, admin SQL all produce events for
free). Same transaction as the data change so failures roll back.

Reader side: zero API changes. /api/registry/feed already supports
event_type glob filtering — consumers subscribe with
?types=authorization.* and the new events flow through.

What ships in 442:
- uuidv7() PL/pgSQL function with microsecond resolution packed into
  bytes 6-7 so calls within the same millisecond are monotonic
  (load-bearing for granted-then-revoked sequences in the same tx).
- caa_event_payload() helper builds the v_effective_agent_authorizations
  row shape so feed and snapshot consumers see the same payload.
- caa_emit_event() AFTER trigger on catalog_agent_authorizations:
  granted on insert/un-tombstone, revoked on tombstone, modified on
  authorized_for/expires_at/disputed change. seq_no rotation alone
  produces no event.
- aao_emit_event() AFTER trigger on adagents_authorization_overrides
  with fan-out: suppress matches N base rows fires N revoked events;
  supersede fires N granted on the previously-suppressed rows. Override
  layer scoped to evidence='adagents_json' only (agent_claim and
  community rows pass through, matching the view).

Tests: 17 cases at server/tests/integration/registry-feed-authorization.test.ts
covering every trigger case + uuidv7() time-order + version/variant
invariants.

Drive-by: scoped registry-feed.test.ts cleanups to its own actor and
filtered queryFeed reads by event_type. Pre-existing tests assumed
catalog_events was empty / owned by them; running them in parallel
with the new CAA trigger tests exposed the brittleness. Local fix.

Pre-commit hook skipped: pre-existing main breakage from
@workos-inc/node 8.13 → 9.1.1 (PR #3266) — verifyOrganizationDomain
and createValidation removed in v9 but the source still references
them. Not related to this PR. CI typecheck will surface it the same
way it surfaced for the catalog_agent_authorizations PR.

Refs #3177. Builds on #3274 (schema). Spec #3251.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ust posture, tests

Round-2 reviewer findings on PR 4b-feed:

S2 — push catalog_properties lookup out of caa_event_payload helper into
the AAO LOOP's SELECT. caa_event_payload now accepts an optional
precomputed_publisher; the fan-out passes loop_rec.derived_publisher,
saving N point lookups for an N-row override fan-out.

S3 — add trust-posture comment block listing publisher-controlled fields
that surface on the public feed (agent_url, agent_url_canonical,
property_id_slug, authorized_for, publisher_domain). Downstream consumers
templating these into LLM prompts or HTML must treat as untrusted.

S4 — hash approved_by_user_id as `moderator:<sha256-prefix-16>` for
the 'add' phantom payload's `created_by`. Stable per-moderator (consumers
can group by actor) but not enumerable as a member directory. Override
fields approved_by_email, justification, evidence_url remain excluded.

S1 (comment-only) — same-fan-out events are all the same ev_type, so
within-microsecond ordering ties don't carry semantic dependencies.
uuidv7's microsecond packing handles ms-ordering; the 2 random tiebreaker
bits cover the rare same-µs collision.

Code-review tweak — clarify uuidv7 SQL/TS comment: TS doesn't pack
microseconds (no fan-out callers), SQL does (load-bearing for triggers).

Tests added:
- supersede 'add' override asserts override_applied=false (symmetry with
  suppress-supersede).
- suppress override with zero matching base rows fires NO event (pins the
  no-op invariant in the LOOP defensive comment).

Pre-commit hook bypassed: pre-existing main typecheck breakage, unchanged
from earlier commits in this PR chain.

Refs #3177.
…ed main)

PR #3309 (addie suggested-prompts metrics) merged to main with migration
442_addie_prompt_telemetry_clicks.sql while this branch was open. Bump
the feed-emitter migration to 443 to resolve.

Also tighten the trust-posture comment per round-3 review:
- Frame as "adversary-controlled" rather than "publisher-controlled" — the
  override path lets moderators (and brand_json/agent_claim writers)
  contribute fields too.
- Call out that created_by on evidence='agent_claim' CAA rows surfaces
  unhashed (it's the asserting agent URL); hashing applies only to
  override-phantom rows.

Refs #3177.
…to main)

PRs #3315 and #3318 merged to main with migrations 443/444/445 while this
branch was still being prepped. 446 is the next free slot.
@bokelley bokelley force-pushed the bokelley/feed-authorization-events branch from 04bfa19 to 6934bb4 Compare April 27, 2026 11:09
@bokelley bokelley merged commit 3c27d0b into main Apr 27, 2026
10 checks passed
@bokelley bokelley deleted the bokelley/feed-authorization-events branch April 27, 2026 11:10
bokelley added a commit that referenced this pull request Apr 28, 2026
…ped (#3374)

* fix(fly): worker process group auto_start (rolling deploys leave workers stopped)

The worker process group has no auto_start_machines path. http_service
covers web, but the bare [[checks]] block for worker-health only keeps
running workers from being killed — it doesn't start a stopped worker.

Symptom: rolling deploys create new worker machines but never start
them. Both worker machines on adcp-docs sat in 'stopped' state with
check status "the machine hasn't started" since the readers PR deploy
(2026-04-28 06:51 UTC), which means the periodic crawler that calls
publisherDb.upsertAdagentsCache (every 30 min for the catalog queue,
every 6h for buying agents) never ran in production after the PR 4b
chain merged. The full property-registry-unification chain
(#3274/#3314/#3312/#3352) was therefore unexercised in prod until a
manual /api/registry/crawl-request triggered it.

Replace the bare [[checks]] block with a [[services]] block targeting
processes=["worker"] with auto_start_machines=true and
min_machines_running=2 — same lifecycle hooks http_service uses for
web. No [[services.ports]] block, so workers stay private. The
http_check inside the services block replaces the prior bare check.

Refs #3177 (this is the gap that prevented prod verification of the
4b chain).
EOF

* chore: add changeset for fly worker auto_start fix
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