Skip to content

feat(gateway): server-side unmet-demand capture, flag default OFF (R44 D2, stacked on #365) - #387

Merged
LamaSu merged 5 commits into
masterfrom
feat/kit-demand-capture
Oct 3, 2026
Merged

LamaSu merged 5 commits into
masterfrom
feat/kit-demand-capture

Conversation

@LamaSu

@LamaSu LamaSu commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

Summary

The gateway half of the kit-demand work, stacked on #365. With PCC_UNMET_CAPTURE_ENABLED=true (default OFF), the first-party intent capture points record two server-owned facts.

  1. Who made the request. It comes only from the server-side auth context, never from the body, and is recorded at one of two strengths:

    • authenticated_operator: a proven wallet only. That is an /a2a SIWE session, or req.provenWallet once the gateway's identity binding sets it. Nothing sets req.provenWallet yet, so /api routes record none.
    • authenticated_key: a plain API-key holder. The key's operatorId is self-asserted at provisioning, so it counts as volume, never breadth.

    All identity reading for capture lives in services/unmet-capture.ts.

  2. Which requested types no live supply can serve (fulfillmentPath plus unmet), from CapabilityFacade.listByType:

    • Live supply decides whether a type is served. The CSD registry decides only the key.
    • Every listed entry passes UnmetCapabilitySchema: a registered type is keyed by its canonical CSD URI, and a type with no CSD appears only as no_capability_type with a slug.
    • An unmet type that cannot be keyed that way is counted, not listed. The intent is still marked unfulfilled, never auto.
    • Matching runs off the response path and never rejects. It checks at most 16 types per intent and records unmetTruncated, and it reads supply through a bounded per-type 30 s cache.

Separately and unconditionally, POST /api/intents/ingest parses with CallerDemandEnvelopeSchema, which has no fulfillmentPath, unmet or unmetTruncated, and strips them again. A caller can never assert unmet demand.

With the flag OFF, the capture points emit exactly what they emit today.

Changes

File Change
packages/gateway/src/services/unmet-capture.ts new: principal extraction at two strengths, flag, bounded cached supply reads, schema-checked matcher, envelope stamping, off-path emit
routes/requests.ts, routes/negotiation.ts matcher plus principal, flag-gated, off the response path
routes/a2a-tasks.ts the resolved key or SIWE session is passed down explicitly
routes/nl-query.ts principal only (its type is unknown_synthetic, so there is no matching)
routes/intent-ingest.ts parses with CallerDemandEnvelopeSchema, plus the strip
services/event-bus.ts, packages/spec/src/types/analytics.ts authenticated_operator and authenticated_key added to the actorType union
packages/spec/src/types/demand.ts server-owned unmetTruncated; the strip helper covers it

Not in this PR

  • price_exceeds_budget and region_unavailable are not computed: there is no reliable price or service-area supply data yet.
  • funded evidence is a follow-up.
  • A2A quote failures still emit no intent.
  • Proven identity on /api routes waits for the gateway's identity binding. Until then, breadth there is 0. An /a2a SIWE wallet already counts as proven, following the gateway's definition. Such wallets cost nothing to mint, so whether they are enough for a public k is an open decision, and the public feed waits for it.

Tests

All run locally at 991e635b. CI runs suites only on PRs based on master, so this stacked PR gets no suite run until it is retargeted.

Package Typecheck Tests
@pcc/gateway clean 3026 passed, 6 skipped, 0 failed across 189 files
@pcc/spec clean 865/865
@pcc/demand-intel clean 55/55
  • unmet-capture.test.ts (31 tests) covers:
    • the flag;
    • both principal strengths, and that body fields are never read;
    • every matcher outcome;
    • schema-consistent keys (no capacity for an uncatalogued type, non-canonical CSD URLs, and unslugifiable names all end up unkeyed);
    • the cache TTL and bound;
    • truncation;
    • the never-rejecting emit.
  • unmet-capture-routes.test.ts (14 tests) runs against real routes and a seeded store. It covers:
    • flag OFF is unchanged;
    • key holders are labelled authenticated_key, and proven wallets authenticated_operator (lower-cased);
    • smuggled identity fields are ignored;
    • ingest drops all three server-only fields.
    • Seam (capture → lens → buildPublicRelease):
      • 4 proven principals stay private and 5 publish 5-9 for the period;
      • a repeat wallet adds volume, not breadth;
      • an unapproved type stays private;
      • key holders and proposed types never publish.

Review and merge

🤖 Generated with Claude Code

…URE_ENABLED (R44 D2)

The gateway half of R44, stacked on the kit-demand signal (steward ruling
#2634). With PCC_UNMET_CAPTURE_ENABLED=true (default OFF), the four
first-party intent capture points (requests, negotiation, nl-query, A2A)
record:

- the authenticated principal as actorType "authenticated_operator", taken
  only from the server-side auth context: apiGate's req.operatorId, or the
  API key the A2A route resolves itself. Never from the request body. All
  capture-time identity reading lives in services/unmet-capture.ts
  (principalFromApiKey), with a TODO to move to WP-A's identity binding.
- fulfillmentPath and unmet from a pure matcher over live supply
  (CapabilityFacade.listByType: instances, availability, kernel status,
  tiers). Live supply decides served versus unmet; the CSD registry only
  decides the key (URI if a CSD exists, else a kebab slug). The first design
  checked the registry first and misreported a type with live instances
  but no CSD (hplc on the seeded store) as unmet; a probe caught it and a
  regression test pins the fix.

Unconditionally, POST /api/intents/ingest now drops caller-supplied
fulfillmentPath and unmet (server-owned fields).

"authenticated_operator" is added to AnalyticsEvent.actorType, and the
event bus references the spec union instead of duplicating it.
stripServerOnlyDemandFields' generic is loosened to accept zod-inferred
envelopes. Flag OFF: the capture points emit exactly what they did before;
a route test pins this.

Tests: 17 unit + 10 route/seam, including the seam from route capture
through UnmetDemandLens to toPublicOpportunityAggregate (4 authenticated
operators stay private, 5 publish "5-9", a repeat operator adds volume not
breadth, and proposed types are never public). Premises about the seed
are asserted, not assumed. Full @pcc/gateway suite: 189 files, 3008
passed, 6 skipped, 0 failed; tsc clean. @pcc/spec 842/842 and
@pcc/demand-intel 46/46, tsc clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBSkBhKBWPA9AeCJ7J5G7a
LamaSu added a commit that referenced this pull request Sep 28, 2026
…ound-1 F1-F5)

The round-1 cross-family review of #365 @e5fa22ba (pack 10) returned
DO-NOT-SHIP as a public feed. This commit closes the pure-layer half of
each finding. The publisher (no-parameter read surface, write-once
release ledger, approved set) belongs to pcc-kits (#3405).

F1  toPublicOpportunityAggregate has no policy parameter. One frozen
    PUBLIC_RELEASE_POLICY: k = 5, floor authenticated_order, 24 h grace.
F2  Only canonical UTC calendar-month periods ("YYYY-MM"), released only
    after close + grace. Any other window, an open period, or a signal
    computed before its period closed throws. buildPublicRelease()
    returns one deterministic release with a sha256 digest. The lens
    gains computeForPeriod().
F3  UnmetCapabilitySchema requires the key form to match the reason:
    no_capability_type only as a slug, every other reason only as a CSD
    URI. resolveCapabilityKey checks the reason first and can take a
    registered set. The projection emits only exact members of the
    publisher's approved set that pass isPublishableCapabilityId (slug
    at most 40 chars, at most 3 hyphens, no run of 4+ digits). The
    release records the approved set's digest.
F4  asOf is gone. The aggregate (now v1) carries the period label.
F5  CallerDemandEnvelopeSchema (no fulfillmentPath, no unmet) for caller
    input. The ingest route switches to it in #387.

Tests cover the verdict's adversarial cases: two k values, sliding
windows at millisecond precision, the CSD-shaped no_capability_type
counterexample, and one unverified late intent. Each guard was also
mutation-checked locally: removing it fails the suite.
spec 865/865, demand-intel 55/55.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu and others added 3 commits September 28, 2026 16:19
…ture off the response path (gateway review #2975)

Gateway reviewed #387 @98e8e490 as SHIP_WITH_FIXES, with two items to
close before anyone turns PCC_UNMET_CAPTURE_ENABLED on:

1. An API key's operatorId is self-asserted at provisioning, so a key
   holder is not a verified requester. Flag-ON capture now records two
   strengths: "authenticated_operator" only for a proven wallet (an /a2a
   SIWE session, or req.provenWallet once gateway's #326 follow-up binds
   it), and "authenticated_key" for a plain key holder. The lens counts
   only the first as verified breadth. Until a proven wallet exists on
   /api routes, breadth there stays 0, which fails closed.
2. Matching ran on the response path and was unbounded. It now runs off
   the response path (captureUnmetThenEmit, which never rejects), matches
   at most 16 distinct types per intent (recording unmetTruncated), and
   reads supply through a bounded per-type 30 s cache.

Flag OFF still emits exactly the previous events, synchronously.
gateway 3019 passed / 6 skipped (189 files).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t parses the caller schema (PX-13 round-1 F3, F5)

Follows the merge of feat/kit-demand-signal @6377dd5d (the #365
round-1 fixes).

F3  computeUnmet lists an entry only if it passes UnmetCapabilitySchema:
    a registered type only by its canonical CSD URI, a type with no CSD
    only as no_capability_type with a slug. An unmet type that cannot be
    keyed that way is counted as unkeyed, not listed: no capacity or no
    tier for an uncatalogued type, a registered URL that is not a
    canonical CSD URI, or a name that does not slugify. Such an intent
    is still marked "unfulfilled", never "auto".
F5  POST /api/intents/ingest parses with CallerDemandEnvelopeSchema,
    which has no fulfillmentPath, unmet or unmetTruncated. The strip
    stays as a second guard. The lens header now describes D2's two
    actor strengths.

The seam tests now run capture -> lens (computeForPeriod) ->
buildPublicRelease. A type publishes only at 5 proven principals, for a
closed period, and only when the publisher approved its CSD. Key
holders and proposed types never publish.
spec 865/865, demand-intel 55/55, gateway 3026 passed / 6 skipped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@LamaSu
LamaSu changed the base branch from feat/kit-demand-signal to master October 3, 2026 01:21
@LamaSu LamaSu closed this Oct 3, 2026
@LamaSu LamaSu reopened this Oct 3, 2026
@LamaSu
LamaSu marked this pull request as ready for review October 3, 2026 01:40
@LamaSu
LamaSu merged commit 7b83f71 into master Oct 3, 2026
10 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.

1 participant