Skip to content

feat(gateway): operator-onboarding funnel stages (ADK track item 4) - #469

Merged
LamaSu merged 4 commits into
masterfrom
feat/operator-funnel-stages
Oct 3, 2026
Merged

LamaSu merged 4 commits into
masterfrom
feat/operator-funnel-stages

Conversation

@LamaSu

@LamaSu LamaSu commented Sep 29, 2026

Copy link
Copy Markdown
Owner

Summary

ADK / hackathon track item 4: an operator-onboarding funnel beside the existing buyer funnel. It tracks each kernel's progression from new kernel to verified operator:

kernel_created → device_registered → adapter_ready → capability_published → test_job_passed → verified_run

It is inert unless PCC_FUNNEL_ENABLED=true, the same switch as the buyer funnel.

  • Keyed on kernelId, not trace_id. Operator onboarding spans separate CLI and daemon calls over days, and a kernel id exists at every stage.

  • Recorded by explicit calls after success, not by an onResponse classifier. Two stages can't be judged from the status code:

    • adapter_ready lives inside the device health check;
    • the setup test job returns 200 on both its honest path and its self-attesting path.
  • Where each stage is recorded:

    Stage Recorded by Counts only when
    kernel_created POST /api/kernels a kernel was created
    device_registered POST /api/setup/register-device the device was created or updated
    adapter_ready device health check (JobFacade.checkDeviceHealth) the device is healthy and its adapter is real (not mock or generic-http), with the kernel resolved from the device's own row
    capability_published POST /api/capabilities a capability was created (201)
    test_job_passed POST /api/setup/test-job the device path completed, with a real device id and a kernel id the caller supplied explicitly
    verified_run nothing on master —
  • Guarantees: each stage is recorded once per kernel, with bounded memory. The sinks are an audit row (operator.funnel, actor = the server-side operator), PostHog (no operator id) and an OTel span event. Every call is wrapped so telemetry can never break a request, and no handler behaviour changes.

  • Admin view: GET /api/admin/observability/operator-funnel?since= returns distinct kernels per stage and conversion from kernel_created. It uses the existing fail-closed observability gate and needs the funnel flag.

Interactions

WP-C (#445) adds kernel-owner checks to register-device and to capability creation. This PR's recorder calls sit after success in those same handlers. Once #445 lands, ownership is checked first, so the funnel records only for the kernel's owner. Expect mechanical rebase conflicts.

Tests (from master ac86a404)

Check Result
operator-funnel.test.ts (new) 41/41
funnel-tracker.test.ts (existing) 6/6
full @pcc/gateway suite 3022 passed, 6 skipped across 188 files
tsc --noEmit (gateway) clean
  • Route-level coverage: every call site, including the traps. The self-attest path, an omitted kernel, a non-completed test job, and a healthy mock or generic-http adapter all record nothing.
  • Mutation checks: removing the placeholder guards, the completed-status gate, the mock-adapter exclusion, the flag gate or the dedup each fails the suite.
  • Authorship: written by a delegated implementer to the lane's spec (from a verified route inventory), then reviewed by the lane.

Review

  • Gateway reviews. A cross-family runner pack follows.
  • Merge is the operator's call.

🤖 Generated with Claude Code

LamaSu and others added 3 commits September 29, 2026 16:51
Adds a second funnel, parallel to the buyer/agent-onboarding funnel in
funnel-tracker.ts, tracking operators through kernel_created ->
device_registered -> adapter_ready -> capability_published ->
test_job_passed -> verified_run. Keyed on kernelId (not trace_id) since
operator-onboarding steps are separate CLI/daemon calls spread over
minutes-to-days.

Unlike the buyer funnel's onResponse+detectStage hook, most of these
checkpoints can't be classified from method+route+statusCode alone (per
the lane's stage-inventory research): test_job_passed returns HTTP 200
on both the honest and self-attested paths, and adapter_ready isn't a
route at all. Recording instead happens from explicit call sites, each
gated on the real success condition and wrapped in try/catch so funnel
telemetry can never break a request:

  - kernel_created: POST /api/kernels, only on the created-kernel branch
  - device_registered: POST /api/setup/register-device (create + update)
  - adapter_ready: JobFacade.checkDeviceHealth, gated on healthy===true
    and a real adapterType (excludes mock/generic-http), both resolved
    from the device's DB row. Extracted isRealAdapterHealthy() so the
    gate is independently unit-testable.
  - capability_published: POST /api/capabilities, only on 201 create
  - test_job_passed: only at the real-device-path return, gated on
    finalStatus==="completed", a non-empty submitResult.deviceId, and
    kernelId supplied explicitly (never the "kernel_dev_001" default) —
    the deviceless self-attest branch structurally cannot reach it

Adds GET /api/admin/observability/operator-funnel behind the existing
admin guard (funnel flag + PCC_OBSERVABILITY_ADMINS).

verified_run is defined in the type but has no recorder yet: nothing on
master can prove a job's evidence was signed by the kernel's own
registered key (that lands with the unmerged D4a readiness check,
PR #428) and the setup test-job path must never claim it (self-attest
or an unverified real-device run are not the same as a verified run).

47 new/changed tests in operator-funnel.test.ts + funnel-tracker.test.ts,
all passing. Full gateway suite: 188 files, 3022 passed, 6 skipped (zero
regressions vs. the 187/2981/6 baseline). tsc --noEmit clean.

Agent: implementer-charlie

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

Lane review of the operator-funnel call sites.

- device_registered, capability_published and test_job_passed now pass the
  server-side req.operatorId, as kernel_created already did, so their audit
  rows name the operator instead of "unknown". PostHog still carries no
  operator id, because it can be an email.
- kernel_created uses a typed request cast instead of `any`.
- getOperatorStagesForKernel documents its 10,000-row audit scan. The audit
  query can't filter by resource id.

Tests: operator-funnel 41/41 and funnel-tracker 6/6. The lane
mutation-checked five guards (placeholder kernel, completed status, mock
adapter, flag, dedup), and every removal fails the suite.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ess (#469 round 1)

The round-1 cross-family review of 6cf7067 (a text-only run) returned
SHIP-WITH-FIXES with four MEDIUM findings. Each was first reproduced at
6cf7067 with a throwaway test (6 of 6 pass).

F1: test_job_passed trusted the caller's kernelId. A completed job on a
mock device, on another kernel's device, or for a nonexistent kernel all
counted. The recorder now resolves the device that ran and requires three
things: that device belongs to the named kernel, the kernel exists, and
its adapter is a real one (isExecutingAdapter: not mock or generic-http).

F2: adapter_ready carried no actor, and any caller counted. Both
test_job_passed and adapter_ready now require an authenticated caller and
record their operator. The health route passes req.operatorId into
JobFacade.checkDeviceHealth. Ownership of the kernel itself is WP-C's
guard (#445); nothing is built on that unreviewed PR here.

F3: the health check read the device row after the check, so a device
moved mid-check was credited to its new kernel. The row is now
snapshotted before the check, and the stage is recorded only if kernel
and adapter are unchanged afterwards.

F4: a stage was marked recorded before its audit row was written, and
eviction, a restart or a second instance could write it again.
- auditService.log now returns whether the row was written.
- The stage counts as recorded only after a confirmed write.
- A durable check first asks the audit log for an existing
  (kernel, stage) row, through a new optional resourceId filter on the
  audit query (a WHERE clause; no schema change).

Tests: 8 funnel regressions plus a db filter test. The eviction test now
asserts no duplicate row. Mutation-checked: 8 of 8 removals fail. gateway
3030 passed / 6 skipped (188 files), db 20/20, tsc clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@LamaSu
LamaSu marked this pull request as ready for review October 3, 2026 01:02
… in capabilities.ts

#469 was SHIP at a9dbf24 but master moved and the PR stopped merging.
The one conflict, POST /api/capabilities' created branch: #469's
capability_published recorder runs first, unchanged, then master's 201
response with ignoredFields and hints (astra pack 111 MEDIUM 4), unchanged.
setup.ts merged automatically beside master's N83 register-device changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu added a commit that referenced this pull request Oct 3, 2026
…t unguarded (#450 onto #469)

Astra's SHIP of the merge-up (8e00dad) noted a MEDIUM test gap: with #450's stricter route,
F1a (mock device) and F1b (kernel-la) are refused before the funnel's own conditions run, so
removing `device?.kernelId === kernelId` or `isExecutingAdapter(device.adapterType)` from
setup.ts left every funnel test passing. Reproduced first: each mutation kept 50/50 green.

- F1d: a passed run whose job reports a device of another kernel records no test_job_passed.
- F1e: a passed run on a generic-http (placeholder) device records none.

Both runs pass #450's route (passed:true), so only the funnel condition keeps the stage out.
Mutation check: each removal now fails exactly its own test (51/52). Test-only; 52/52, tsc clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBTXKQ2uqtcrsUtPnQdtf8
LamaSu added a commit that referenced this pull request Oct 3, 2026
…d #5832)

The steward's MERGE NOW simulation found #475 (N85, SHIP @8190cee9)
conflicting with #469 (operator funnel stages, c964afa) in
packages/gateway/src/facades/job.facade.ts, so #475 now merges after #469.

The one conflict was the import block: #475 imports writeJobStatusGuarded
and SETTLEMENT_OWNED_MESSAGE; #469 imports recordOperatorStage and adds
two exported helpers. Both sides are kept, #475's line first. Every other
hunk merged automatically: the guarded status write and the funnel tracking
in the health check are in different functions.

Results: the targeted suites (settlement-owned-status, carrier x3,
funnel-tracker, operator-funnel, job-status-vocab) pass 148/148. Full
gateway suite: 3781 passed, 13 skipped. tsc is clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSBxBEX3sn9DPSqihyjuZE
LamaSu added a commit that referenced this pull request Oct 3, 2026
Merges PR #469 (operator-onboarding funnel stages, ADK track item 4)
into #448's branch (fix/n68-coarse-kernel-location).

Conflict: packages/gateway/src/routes/kernels.ts
  - Both #448 and #469 add a new import line immediately after the
    existing `import type { CreateKernelInput, ... } from
    "../facades/index.js";` line, so git could not pick an order.
  - #448 wants: `import type { LocationOptInAuthority } from
    "../facades/kernel.facade.js";` (board N68 — the type for the
    POST /api/kernels location-visibility authority check).
  - #469 wants: `import { recordOperatorStage } from
    "../services/funnel-tracker.js";` (operator-onboarding funnel
    telemetry).
  - Resolution: kept both import lines (facades import before services
    import, grouped by path). Nothing else in the file conflicted —
    git auto-merged the POST /api/kernels handler body cleanly, so
    #448's `locationOptInAuthority(req)` argument to facade.register()
    and #469's recordOperatorStage() try/catch in the `if (created)`
    block both survive untouched, each using its own now-present
    import.

agent: pcc-readmodels (c255d7dc)
@LamaSu
LamaSu merged commit 55ca54a into master Oct 3, 2026
9 checks passed
LamaSu added a commit that referenced this pull request Oct 3, 2026
The PR steward asked for #448 to be merged up with #488 and #469 (bus #5831).
This restacks #533 onto that head.

agent: pcc-readmodels (c255d7dc)
LamaSu added a commit that referenced this pull request Oct 4, 2026
…iles

Conflicts and how each keeps both sides:
- db IAuditLogRepository, repositories/audit-log, services/audit-service: master's #469 added a
  resourceId filter; #538 stores resource ids keyed, so resourceId joins eventType, actor and
  resourceType as a both-forms filter (the raw value and its keyed hash).
- facades/job.facade, routes/capabilities, routes/operator-relay: both sides' imports (#538's
  closed-schema helpers; master's settlement-owned-status and funnel-tracker).
- facades/kernel.facade: #538's declared audit fields plus master's locationVisibility, declared
  from the closed vocabulary ["exact","approximate"].
- server.ts: #538's closed logger (gatewayLoggerOptions, requestIdHeader false, genReqId) replaces
  master's GATEWAY_LOGGER_OPTIONS serializer, which it subsumes (no URL, header or raw IP ever).
  The write-audit hook keeps master's module (services/write-audit-hook.ts, #458) with #538's
  declared fields ported in (route template, closed method and action, server metrics), and
  master's telemetryLookalikeHook stays.
- observability/closed-sinks: the request log line omits even the keyed client hash for the
  public telemetry sink (#458's rule).

agent: pcc-readmodels (c255d7dc)
LamaSu added a commit that referenced this pull request Oct 4, 2026
…hema

The master merge (63401c2) left 8 failing tests in 4 files; each is master code that
postdates #538's base meeting the closed observability schema:
- the logger ratchet: 7 raw log messages in routes/operator.ts, routes/operator-work.ts and
  readmodels/legacy-settlement.ts now go through lit().
- the timing ratchet: services/funnel-tracker.ts (#469) built its span event with spreads; it
  now uses addClosedEvent with declared fields (the stage from OPERATOR_STAGES, the ids
  declared) and no spread.
- routes/operator-channels.ts: master's N84 added dns_busy to OutboundErrorCode, but #538's
  hardcoded OUTBOUND_ERROR_CODES lacked it, so a saturated resolver's code was keyed out of the
  operator log N84 keeps it in. The vocabulary is now a Record over the union (complete by
  construction: a new code fails to compile until listed). n84-dns-bound reads the warning's
  declared arguments as they leave.
- feedback-attempt-production (#458): its app now mirrors server.ts's closed logger
  (gatewayLoggerOptions, issued request ids, closedLoggerHooks); audit rows are found by their
  declared route template; the test that pinned a raw URL in other routes' audit now pins the
  closed rule (route template only, no query), and the log-line test pins method plus route,
  with the client's keyed hash for other routes and nothing at all for the sink.
Targeted: the 4 files pass (ratchets and funnel 56, feedback 12, n84 19); tsc 0.

agent: pcc-readmodels (c255d7dc)
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