Perl to Rust

In a modernisation the hours go to testing and validation. Bosun ran Netdisco's migration as a series of automations, each ending in a document or a pull request an engineer reviewed, with observed behaviour proven. In this technical case study we explore Bosun's ability to effectively modernise Netdisco to Rust, in small, proven slices.

slices merged
4 #60, #100, #101, #103 on the fork
blocked run
1 #98: 16 passed, 60 failed
proofs / cases
91 / 161 all passing on the merged head
lines merged
27.0k four slices, one reviewing engineer

What we set out to test

In legacy migration, one big challenge we see is risk management. What happens if we deploy? Is it correct? Can we roll it back? And even: do we understand what we are migrating?

With the rise of GenAI, it becomes straightforward to instruct an agent to migrate any bit of code. At scale, we need to manage risk, which means small sets of change and verified proof.

The subject

Netdisco is a popular, maintained, Perl and Python network management application. It features a web interface, several background workers that gather and interact with the network, and various visualisation instruments.

  1. Can Bosun discover enough architecture and behaviour to identify a safe seam?

  2. Can it produce a defensible migration decision rather than immediately translating code?

  3. Can it identify migration patterns and language quirks?

  4. Can it identify seams: good first entry and end points?

  5. Can it bootstrap a migration architecture?

  6. Can the migrated slices be compared with the legacy using executable specifications?

  7. Can change requests include enough evidence to minimise review time?

How we ran the migration

With Bosun we build structured workflows around code, integrating with engineering tools, context, and documentation, so we can break up complex work into controlled, repeatable steps. Workflows run in strong isolation (MicroVMs) so agents have full freedom.

Each workflow produces one artefact, a document or a pull request, and stops there until an engineer has reviewed it. Six workflows carried this migration.

Bosun's modernisation overview for Netdisco: Assess and Strategize done, Plan with 58 slices, Build 3/58 complete with one pull request open, the slice map and the latest runs.Bosun's modernisation overview for Netdisco: Assess and Strategize done, Plan with 58 slices, Build 3/58 complete with one pull request open, the slice map and the latest runs.
The Netdisco migration in Bosun: 58 slices, three complete, one verifying. dumpconfig merged while Parity in CI was still advisory; its 15 cases pass on master today.
  1. Assess

    Codebase Assessment

    Agents assess the user journeys, features, architecture and tests in parallel, and one agent synthesises their findings into a single Markdown document. Using our graph-based intelligence, agents (and humans) can effectively navigate, understand, and verify complex queries.

    Migration Codebase Assessment

    Diagram: Architecture OverviewDiagram: Architecture Overview

    Migration Codebase Assessment

    Executive Summary

    Netdisco is a Perl network-management system for inventorying managed devices and locating endpoints. It ships a Dancer/Plack web application, a REST API, a PostgreSQL-backed inventory and job system, MCE workers, an operational CLI, dynamic plugins, reports and Python worklets.

    The assessed checkout is 252810a9, clean and identical to master. It contains no project-owned Rust source, Cargo.toml or lockfile. Earlier Rust bootstrap commits were reverted and are not a design to restore. No files were changed for this assessment.

    The goal is to introduce Rust incrementally. The main constraint is preserving PostgreSQL data and job contracts, worker and plugin phase behaviour, configuration and extension interfaces, browser and API compatibility, and operational behaviour throughout.

    Architecture Overview

    Diagram: Architecture OverviewDiagram: Architecture Overview

    The web process and the backend process are separate. Web requests, the scheduler and netdisco-do --enqueue all produce queue rows in PostgreSQL; the backend claims and executes them.

    Current Architecture

    • Entrypoints:
      • Web: Starman via bin/netdisco-web-fg:32.
      • Backend: bin/netdisco-backend-fg:49 and bin/netdisco-backend-fg:60.
      • Job submission: netdisco-do --enqueue via bin/netdisco-do:192.
    • Web/API: Dancer/Plack serves server-rendered views, AJAX and /api/v1, with role-based access, API tokens, delegated authentication, Swagger, tenant-prefixed routes and optional health and metrics endpoints. bin/netdisco-web-fg:32 lib/App/Netdisco/Web/AuthN.pm:55
    • Data model: PostgreSQL is the system of record for inventory, users, sessions, audit records, reports, job lifecycle and per-backend device deferral state. Schema version is 96. Devices are keyed by IP, ports by (port, ip), nodes by (mac, switch, port, vlan). lib/App/Netdisco/DB.pm:14 lib/App/Netdisco/Configuration.pm:71 lib/App/Netdisco/DB/Result/Device.pm:13 lib/App/Netdisco/DB/Result/DevicePort.pm:564
    • Workers/jobs: The backend runs one scheduler, one queue manager and configurable pollers through MCE. PostgreSQL holds the durable queue; MCE is a local in-memory transfer queue. A job carries action, device, port, payload, submitter and IP, backend, timestamps, status and log. bin/netdisco-backend-fg:49 bin/netdisco-backend-fg:60 lib/App/Netdisco/JobQueue/PostgreSQL.pm:194
    • Integrations: Discovery and collection use SNMP, CLI, device APIs and Python worklets. Worklets exchange job, configuration, stash and status data with a persistent subprocess and are enabled by default. share/config.yml:838 lib/App/Netdisco/Transport/Python.pm:36
    • Operations: Users search by IP, MAC, device, port or VLAN; browse devices and topology; run reports; inspect SNMP snapshots; administer users, topology, queue entries, monitored nodes and diagnostics. Port changes are audited and queued for asynchronous execution. lib/App/Netdisco/Web/PortControl.pm:10

    Feature Overview

    FeatureUser/System ValueSource EvidenceMigration Notes
    Web and REST APIBrowser UI, AJAX, /api/v1, roles, API tokens, delegated auth, Swagger, tenant routes, health and metrics.bin/netdisco-web-fg:32 lib/App/Netdisco/Web/AuthN.pm:55Preserve URLs, roles, authentication semantics, static paths, templates, plugin registrations, report/search/device tabs and write attribution.
    Network inventoryDevice, interface, VLAN, module, power, neighbour, wireless, MAC, ARP, endpoint and node history.lib/App/Netdisco/DB/Result/Device.pm:13 lib/App/Netdisco/DB/Result/DevicePort.pm:564Preserve device-IP keys and port and node key structures.
    User operationsSearch, topology and device browsing, reports, SNMP snapshots, administration, diagnostics, port changes.lib/App/Netdisco/Web/PortControl.pm:10Port changes keep audit records and asynchronous queueing.
    Job lifecycleDurable queued jobs with action, device, port, payload, submitter, backend, timestamps, status and log.lib/App/Netdisco/JobQueue/PostgreSQL.pm:194 lib/App/Netdisco/Worker/Status.pm:23Preserve defer, done, info and error semantics and observable transitions.
    Worker orchestrationScheduler, queue manager and configurable pollers through MCE.bin/netdisco-backend-fg:49 bin/netdisco-backend-fg:60Preserve plugin phase order, driver selection, credential filtering, timeouts and status aggregation.
    Dynamic extensionsWeb and worker plugins, site plugins and templates, configured reports, Python worklets.lib/App/Netdisco/Web.pm:144 lib/App/Netdisco/Worker/Loader.pm:20Treat configuration-driven loading as an application contract.
    Configuration and tenancySchedules, ACLs, credentials, tenant connections, auth providers, plugins, reports, hooks, network behaviour.share/config.yml:761 lib/App/Netdisco/Configuration.pm:122Preserve YAML settings, tenant and external databases, exported environment variables.
    Device operationsCollects and updates device, port, VLAN, wireless, neighbour, MAC and ARP data.lib/App/Netdisco/Worker/Plugin/Discover/Properties.pm:520 lib/App/Netdisco/Util/DeviceAuth.pm:126Preserve transaction boundaries, locking, credential handling, timeouts and status mapping.
    CLI automationnetdisco-do --enqueue creates standard jobs; inline mode runs the same runner.bin/netdisco-do:192An available automation and incremental migration boundary.

    Dependency Map

    DependencyEcosystemCurrent RoleMigration StrategyRiskEvidence
    PerlPerl runtimeApplication, web, worker, CLI, database and plugin runtime.Retain during migration, or replace only with compatibility coverage.Wide compatibility and rollback scope if removed.lib/App/Netdisco/DB.pm:14
    PostgreSQLDatabaseSystem of record for inventory, users, sessions, audit, reports, jobs and deferral state.Preserve schema and data contracts; verified backups and fail-fast transactional migrations first.Failed migrations can be marked complete.lib/App/Netdisco/Configuration.pm:71 lib/App/Netdisco/DB/SchemaVersioned.pm:11 bin/netdisco-db-deploy:147
    MCEPerl concurrencyScheduler, queue manager, pollers; in-memory transfer queue.Keep with Perl workers while consumers migrate.Mixed consumers need strict action routing.bin/netdisco-backend-fg:49 bin/netdisco-backend-fg:60
    SNMPNetwork integrationDevice, interface, VLAN, module, power, neighbour, wireless, MAC and ARP collection.Implement selected operations behind a plugin or subprocess boundary.Vendor and MIB behaviour; credential command handling.lib/App/Netdisco/Util/DeviceAuth.pm:126
    CLI/SSHNetwork integrationDevice operations and collection.Migrate representative operations behind an existing worker boundary.Production paths, credentials and rate limits unresolved.—
    HTTP/device APIsNetwork integrationDevice information and operations where supported.Preserve operation semantics and status aggregation.Device-specific behaviour and timeouts.lib/App/Netdisco/Worker/Runner.pm:51
    PythonSubprocess integrationWorklets with job, configuration, stash and status exchange.Retain the subprocess contract until equivalent behaviour is verified.No worklet integration suite identified.share/config.yml:838 lib/App/Netdisco/Transport/Python.pm:36
    Dancer/Plack/StarmanPerl webHosts the web UI and API.Migrate only with routes, sessions, tokens, templates, plugins and static paths preserved.User-visible and security-sensitive.bin/netdisco-web-fg:32
    Dynamic plugins and reportsPerl extensionsWeb and worker plugins, site templates, reports.Define a stable plugin ABI or keep Perl loading while Rust is bridged.Names, namespaces, priorities and shared worker state are implicit coupling.lib/App/Netdisco/Web.pm:144 lib/App/Netdisco/Worker/Loader.pm:20
    netdisco-dockerContainer/deploymentContainer build and runtime definitions from mutable master.Resolve ownership and inputs before relying on reproducible builds.No Dockerfile, compose, Kubernetes or service units in this checkout..github/workflows/build-and-publish.yml:144

    Migration-Relevant Patterns

    • PostgreSQL system of record: inventory, users, sessions, audits, reports, jobs and per-backend deferral state are durable contracts; independent tenant and external databases are supported. lib/App/Netdisco/Configuration.pm:71 lib/App/Netdisco/Configuration.pm:86
    • Observable job lifecycle: defer requeues a job and updates device retry state; done, info and error are terminal. Fields and transitions are API-visible. lib/App/Netdisco/JobQueue/PostgreSQL.pm:194 lib/App/Netdisco/Worker/Status.pm:23
    • Composed worker phases: plugins run in check, early, main, user, store and late order, wrapped by credentials, driver priority, timeouts and status aggregation. lib/App/Netdisco/Worker/Runner.pm:51 lib/App/Netdisco/Worker/Runner.pm:78 lib/App/Netdisco/Worker/Loader.pm:73
    • Configuration-driven behaviour: YAML controls schedules, ACLs, credentials, tenants, authentication, plugins, reports, hooks and network behaviour; environment variables are exported to psql and Python. share/config.yml:761 lib/App/Netdisco/Configuration.pm:122
    • Dynamic extension loading: web and worker plugins, site templates, reports and worklets load at runtime. lib/App/Netdisco/Web.pm:144 lib/App/Netdisco/Worker/Loader.pm:20
    • Replace-in-full discovery writes: discovery deletes and repopulates child collections; port refresh locks all of device_port with ACCESS EXCLUSIVE. lib/App/Netdisco/Worker/Plugin/Discover/Properties.pm:520
    • Queue reclamation without ownership: claims check status = queued, but defer and complete update by job ID alone, so a stale worker can change a re-leased job. lib/App/Netdisco/JobQueue/PostgreSQL.pm:170 lib/App/Netdisco/JobQueue/PostgreSQL.pm:222
    • Shell credential commands: %HOST% is substituted into a shell command on SNMP connection paths. lib/App/Netdisco/Util/DeviceAuth.pm:126
    • Proxy and delegated identity: reverse-proxy handling is installed by the web server; delegated identity may come from request headers and synthesise users. bin/netdisco-web-fg:32 lib/App/Netdisco/Web/AuthN.pm:25
    • External environment: deployment definitions come from netdisco-docker at mutable master. .github/workflows/build-and-publish.yml:144

    Candidate Seams

    SeamTypeSource EvidenceWhy It HelpsRisks
    Web/UI/API serviceService boundarybin/netdisco-web-fg:32 lib/App/Netdisco/Web/AuthN.pm:55Keeps PostgreSQL, workers and Swagger/AJAX contracts while isolating web and API.Roles, sessions, tokens, tenant paths, templates, plugins and asset URLs.
    Selected job consumerWorker boundarylib/App/Netdisco/JobQueue/PostgreSQL.pm:194Rust processes selected actions while Perl producers, UI and scheduler stay.Strict action routing and ownership-safe mixed-consumer updates.
    SchedulerQueue producerlib/App/Netdisco/Backend/Role/Scheduler.pm:71An external scheduler can emit ordinary queue records.Queue insertion and worker expectations must stay compatible.
    Transport/action implementationOperation boundarylib/App/Netdisco/Worker/Runner.pm:51 lib/App/Netdisco/Worker/Runner.pm:78One SNMP, SSH, HTTP or CLI operation behind a plugin or subprocess.Phases, timeouts, credentials and status mapping.
    Plugin bridgeExtension boundarylib/App/Netdisco/Worker/Loader.pm:20 lib/App/Netdisco/Web/Plugin.pm:65Hosts Rust modules while Perl keeps loading local plugins.Needs a stable plugin ABI.
    Read/report data serviceRead-only service—Read-only reporting and search can move before writes.Query output and filtering are user-visible.
    Persistence/platform rewriteFull-system boundarylib/App/Netdisco/DB.pm:14 lib/App/Netdisco/Configuration.pm:71Replaces Perl across schema, migrations, services and extensions.Widest compatibility and rollback scope.

    netdisco-do --enqueue is also an available automation boundary: it produces standard queued jobs, while inline mode runs the same worker runner. bin/netdisco-do:192

    Reproducible Setup

    • Services and state: CI runs perl ./Build.PL && ./Build test --test_files xt/ inside netdisco/netdisco:latest-backend. PostgreSQL is part of persistent state; configuration supports tenant and external databases. .github/workflows/build-and-publish.yml:72 .github/workflows/build-and-publish.yml:77 lib/App/Netdisco/Configuration.pm:71 lib/App/Netdisco/Configuration.pm:86
    • Missing setup: local prove -lr xt stopped before any assertion for lack of CPAN prerequisites, including Path::Class, Test::Pod, Test::File::ShareDir::Dist and Env::Path.
    • Missing setup: no Dockerfile, compose, Kubernetes or service-unit source in this checkout; container definitions come from netdisco-docker at mutable master. .github/workflows/build-and-publish.yml:144

    Test And Verification Surface

    • Existing tests/CI: perl ./Build.PL && ./Build test --test_files xt/ inside netdisco/netdisco:latest-backend. .github/workflows/build-and-publish.yml:72 .github/workflows/build-and-publish.yml:77
    • Existing tests/CI: CI runs only the seven Perl xt/*.t files. .github/workflows/build-and-publish.yml:72
    • Useful verification commands: prove -lr xt
    • Useful verification commands: perl ./Build.PL && ./Build test --test_files xt/
    • Coverage gaps: no PostgreSQL migration, HTTP/API, SNMP/SSH, queue-concurrency, Python-worklet or deployment integration suite.
    • Coverage gaps: acceptance must cover the schema and data contract, job lease and status transitions, representative device workflows, UI/API/CLI compatibility, and rollback under mixed-version operation.
    • Coverage gaps: production configuration and fixtures are not captured. Unknowns: deployment overrides, plugins and templates, reports, hooks, worker plugins, tenant databases, device environments, database characteristics, queue behaviour, compatibility requirements, topology, secrets, TLS, observability and container ownership.

    Risk Register

    RiskImpactEvidenceMitigation Or Follow-Up
    Database upgrades can mark a failed migration complete.A swallowed SQL failure advances the schema-version row and deployment exits successfully.lib/App/Netdisco/DB/SchemaVersioned.pm:11 bin/netdisco-db-deploy:147Verified backups; fail-fast transactional migrations before any production schema change.
    Queue leases are not ownership-safe after reclaim.A stale worker can alter a re-leased job.lib/App/Netdisco/JobQueue/PostgreSQL.pm:170 lib/App/Netdisco/JobQueue/PostgreSQL.pm:222Mixed Perl/Rust consumption needs a backend or lease token in terminal and defer updates.
    Discovery replaces child collections in full.Partial reimplementation breaks consistency; locking blocks parallel workers and API reads.lib/App/Netdisco/Worker/Plugin/Discover/Properties.pm:520Preserve transaction and locking semantics, or prove equivalent behaviour first.
    Phase and plugin ordering are implicit coupling.Action-by-action rewrites can change data or behaviour silently.lib/App/Netdisco/Worker/Loader.pm:24 lib/App/Netdisco/Worker/Runner.pm:51 lib/App/Netdisco/Worker/Runner.pm:78Executable compatibility behaviour for names, namespaces, priorities, drivers, ACL-filtered credentials, Python stages and shared state.
    Credential commands use the shell.%HOST% substitution is an unsafe boundary on SNMP paths.lib/App/Netdisco/Util/DeviceAuth.pm:126Use an argument vector or service protocol instead of a command string.
    Authentication and proxy setup are security-sensitive.Header identity and user synthesis can shift the trust boundary.bin/netdisco-web-fg:32 lib/App/Netdisco/Web/AuthN.pm:25Preserve or deliberately harden the proxy and delegated-identity boundary in any split.
    Deployment sits partly outside the repository.Reproducibility cannot be established from this checkout..github/workflows/build-and-publish.yml:144Resolve netdisco-docker inputs and ownership before choosing deployment scope.
    Current tests give weak migration safety.CI checks no migrations, protocols, devices, queue concurrency, worklets or deployment..github/workflows/build-and-publish.yml:72Build acceptance tests from captured production configuration and fixtures.
    Production requirements are unresolved.Scope and rollback cannot be chosen without them.—Resolve device, database, queue, compatibility, topology, ingress, secrets, TLS, observability, tenant and consumer requirements first.
  2. Decide

    Architecture Decision

    We did not choose workers up front. Agents identify candidate seams, evaluate migration strategies against each other, and rank them; we read the ranking and the proposed target and migration architecture in one document, so we can audit the whole decision. The background workers came out first.

    Migration Architecture Decision

    Decision

    • Selected migration strategy: strangler facade through one Worker Execution Bridge, inserted after the Perl poller claims a job and before the legacy runner. It adds explicit routing, a versioned protocol, receipt tracking and rollback without rewriting producers, routes or queue ownership.
    • Target architecture direction: a Rust modular monolith with independently runnable web, scheduler, worker, CLI and database-maintenance binaries, using explicit PostgreSQL repositories, lease-safe jobs, typed action phases and a capability-limited WASI extension boundary.

    First Shared Seam

    • Production entrypoint: deployed with bin/netdisco-backend-fg (bin/netdisco-backend-fg:32); the Perl poller calls it just before the legacy runner (lib/App/Netdisco/Backend/Role/Poller.pm:30).
    • Why this seam: web requests, scheduled work and enqueued CLI work already converge on the backend, so the bridge reaches all durable execution without touching producers.
    • Default behaviour: an unregistered route, force-legacy mode or a bridge configuration error calls the existing Perl callback exactly once.
    • Preparation pull request: executor protocol and runtime crate, supervised nd-worker-executor, versioned request, response and receipt schemas, Perl bridge mapping, empty route registry, force-legacy configuration, telemetry, validation and tests. No production route, no dual-write mode.
    Diagram: First Shared SeamDiagram: First Shared Seam

    Migration Architecture Decision

    Executive Summary

    Use a single Worker Execution Bridge as the first migration seam. The Perl backend, queue, manager, poller and legacy runner stay authoritative while a supervised Rust executor is prepared for worker migration.

    The target is a Rust modular monolith with separate operational roles: web, scheduler, workers, CLI and migration tools built from one workspace. PostgreSQL stays the system of record and durable queue.

    The first implementation is one small, disabled-by-default Bootstrap pull request. At merge, Perl is the only usable execution path.

    Decision

    • Selected migration strategy: strangler facade through one Worker Execution Bridge, inserted after the Perl poller claims a job and before the legacy runner. It adds explicit routing, a versioned protocol, receipt tracking and rollback without rewriting producers, routes or queue ownership.
    • Target architecture direction: a Rust modular monolith with independently runnable web, scheduler, worker, CLI and database-maintenance binaries, using explicit PostgreSQL repositories, lease-safe jobs, typed action phases and a capability-limited WASI extension boundary.

    Initial Migration Scope

    • Selected seam: Worker Execution Bridge.
    • Initial slice family: existing worker actions, migrated end to end through the bridge.
    • Slice unit: one worker behaviour, with its required support, fallback and parity proof.
    • Completed support: the bridge bootstrap is merged (PR #42, 7 Aug 2026); Perl keeps queue ownership.
    • Later scope: web, scheduler, CLI and queue ownership.

    First Shared Seam

    • Production entrypoint: deployed with bin/netdisco-backend-fg (bin/netdisco-backend-fg:32); the Perl poller calls it just before the legacy runner (lib/App/Netdisco/Backend/Role/Poller.pm:30).
    • Why this seam: web requests, scheduled work and enqueued CLI work already converge on the backend, so the bridge reaches all durable execution without touching producers.
    • Default behaviour: an unregistered route, force-legacy mode or a bridge configuration error calls the existing Perl callback exactly once.
    • Preparation pull request: executor protocol and runtime crate, supervised nd-worker-executor, versioned request, response and receipt schemas, Perl bridge mapping, empty route registry, force-legacy configuration, telemetry, validation and tests. No production route, no dual-write mode.
    Diagram: First Shared SeamDiagram: First Shared Seam

    Bootstrap Needs

    • A root Rust workspace holding only the executor protocol and runtime crate and nd-worker-executor.
    • Versioned request, response, compatibility-manifest and receipt schemas, with equivalent Perl validation and mapping.
    • A strict framed Unix-socket protocol: byte limits, deadlines, rejection of unknown required fields, no stdout diagnostics.
    • A least-privilege request envelope: protocol version, dispatch ID, tenant, job ID, action and namespace, device and port, actor, deadline, route revision, normalised-request hash, timeout, ACL result and one credential capability.
    • A route registry with legacy, observe and rust modes, starting empty; global force_legacy, socket path and protocol-version settings.
    • Additive per-tenant execution receipts keyed by dispatch ID and job ID.
    • Structured telemetry for route, tenant, dispatch ID, versions, decision, duration, outcome and receipt state.
    • CI for rustfmt, Clippy with warnings denied, protocol tests and the existing Perl suite.
    • Tests proving disabled mode reaches Perl once, the executor is not contacted, unknown routes are rejected and post-accept failures do not retry through Perl.
    • Documentation for route promotion and offline replay.

    Rust mode refuses to start until the receipt store and fail-closed, transaction-aware migrations exist.

    Deferred Migration-Enabling Capabilities

    • Offline replay fixtures comparing Perl and Rust outcomes, phase order, logs and declared aggregate state.
    • The complete typed worker action pipeline in Rust, with credential-provider and transport adapter interfaces.
    • Tenant-scoped executor pools and permissions; Rust-owned aggregate write contracts and receipts.
    • Lease-safe queue ownership and stale-worker protection.
    • Rust services for web/API, CLI, scheduling, identity, authorisation, reports and port control.
    • Rust configuration structs, schema validation, source diagnostics and effective configuration.
    • WASI component manifests, capability controls and extension compatibility tests.
    • Fail-closed migrations, backup and restore rehearsal, migration failure injection.
    • Reproducible OCI build inputs and clear ownership of netdisco-docker; executor deployment with readiness checks and fixed socket paths.
    • Removal of the Perl bridge and Perl queue ownership once all worker behaviour has moved.

    Evidence Used

    EvidenceSourceArchitectural Impact
    Scheduling, manager and poller share one process path.bin/netdisco-backend-fg:36, lib/App/Netdisco/Backend/Role/Poller.pm:30Put the seam in the poller, before the runner.
    The web server is a distinct process.bin/netdisco-web-fg:32Do not put the first seam in web.
    Web, scheduler and enqueued CLI work converge on the backend.bin/netdisco-do:192Keep producers; route their shared execution path.
    The backend field means booking and visibility.lib/App/Netdisco/JobQueue/PostgreSQL.pm:178, lib/App/Netdisco/Web/Health.pm:21Do not reuse it as a Rust selector.
    The poller owns completion and deferral.lib/App/Netdisco/Backend/Role/Poller.pm:53Queue transitions stay Perl-owned during coexistence.
    Completion and deferral use job identity without a lease.lib/App/Netdisco/JobQueue/PostgreSQL.pm:209, :222, :253Contain it; use receipts before Rust writes.
    Discovery storage replaces atomically and retains nodes.lib/App/Netdisco/Worker/Plugin/Discover/Properties.pm:520Keep all-or-nothing inventory behaviour.
    Device and node identity are storage contracts.lib/App/Netdisco/DB/Result/Device.pm:13, lib/App/Netdisco/DB/Result/Node.pm:47Keep IP, port and (MAC, switch, port, VLAN) keys.
    Port control records audit and job together.lib/App/Netdisco/Web/PortControl.pm:32Keep audit-plus-enqueue atomic.
    Migration failures can be swallowed.lib/App/Netdisco/DB/SchemaVersioned.pm:11, bin/netdisco-db-deploy:147Fail-closed migrations before Rust-owned writes.
    Workers depend on ordered phases and dynamic loading.lib/App/Netdisco/Worker/Runner.pm:25, :78; lib/App/Netdisco/Worker/Loader.pm:73Compatibility manifest and typed Rust pipeline.
    Python worklets exchange broad configuration and context.lib/App/Netdisco/Transport/Python.pm:75Capability-limited extension ABI.
    Web namespace loading is dynamic.lib/App/Netdisco/Web.pm:144Explicit manifests and contracts in the target.
    The image build fetches mutable external content..github/workflows/build-and-publish.yml:144Pin the input and assign ownership before production.

    Candidate Seams

    SeamTypeSource EvidenceUse In MigrationRisks
    Worker Execution BridgeExecution facade / branch by abstractionlib/App/Netdisco/Backend/Role/Poller.pm:30; completion at :53Route claimed jobs to legacy or RustAmbiguous post-accept failures; needs receipts and no fallback
    Web/API entrypointWeb boundarybin/netdisco-web-fg:32Deferred until worker contracts and services existRewrites routes, auth, sessions, templates and API too early
    CLI inline executionDirect execution pathbin/netdisco-do:192Stays Perl-only during coexistenceA second entrypoint could diverge
    SchedulerJob producerSubmits to the existing producer pathKept; the bridge changes execution onlyRewriting it before shared job contracts
    Persistence and queueData boundaryPostgreSQL is the queue and system of recordDeferred until lease-safe repositories existTwo consumers or conflicting ownership
    Dynamic plugin runtimeExtension boundarylib/App/Netdisco/Web.pm:144Later: Rust registration and WASI componentsLoad order, capability leakage, missing phases

    Option Ranking

    OptionFitVerifiabilityReversibilityCostDecision
    Worker Execution Bridge before the Perl runnerHighHigh: protocol, fixtures, receipts, bridge testsHigh: force legacy, no schema-downLow for BootstrapSelected
    Start at the web/API boundaryLow: not the shared execution pathDeferredMediumHighDeferred
    Rewrite scheduler, producers or CLI firstLow: they already convergeMediumMediumHighDeferred
    Live Perl and Rust in parallelLow: duplicate device operations and writesLow; offline replay preferredLow after one side commitsHighRejected
    Second Rust queue consumerLow: competing queue ownershipLowLowHighRejected
    Restore the reverted BootstrapLow: allowed dual execution, no durable dispatch ownershipLowLowMediumRejected
    Broad microservicesLow: data and application boundaries stay cohesiveLowerLowerHighRejected
    Rust modular monolith with separate rolesHigh: keeps process separation without MCE handoffHighHigh: independently deployable binariesMediumSelected target

    Transitional Architecture

    • Coexistence: the Perl manager books jobs; the poller claims them and calls the bridge, which does one route lookup. backend stays a booking value, not a selector. legacy is the default and only usable mode at merge. observe records a sanitised envelope only, with no credentials, devices, secrets or writes. rust sends one versioned request over a local Unix socket. The executor has no queue consumer of its own. Inline netdisco-do stays Perl-only.
    • Compatibility layer: the bridge converts App::Netdisco::Backend::Job to WorkerExecutionRequest, projects least-privilege configuration and credentials, validates version, route, deadline and request hash, maps responses to done, info, error or defer, bounds and redacts logs, and checks the compatibility manifest (protocol, phases, config keys, credentials, aggregates, timeouts, outcome mapping) before a route can use rust.
    • Data ownership: Perl owns queue records, claims, status transitions, device_skip deferrals, audit submission and terminal updates. Rust owns only a route’s declared aggregate writes and its receipt, committed in one transaction before success is reported. Receipts record accepted, running, committed and terminal outcomes with hashes, are tenant-scoped, and are not a queue. External reporting databases stay read-only.
    • Rollback: force_legacy stops new dispatches without touching routes, producers, queue rows or schema. Only a failure proven before request bytes are accepted may fall back to Perl. After acceptance, timeout, disconnect or malformed completion is ambiguous: no automatic Perl retry; the job is marked recoverable by dispatch ID and reconciled through the receipt or by an operator. No schema-down during an incident.

    Target Architecture

    Diagram: Target ArchitectureDiagram: Target Architecture
    • Modules: nd-domain (validated types, no HTTP, SQL or transport), nd-application (identity, authorisation, reports, inventory, topology, port control, jobs, scheduling, orchestration), nd-persistence-pg (repositories, transactions, migrations). Binaries: netdisco-web (Axum/Tokio, templates, assets, tenant routes, /api/v1, OpenAPI, health, metrics), netdisco-scheduler, netdisco-worker, netdisco, netdisco-db migrate.
    • Data: PostgreSQL stays authoritative; no separate inventory service, queue service, broker or correctness cache. Each tenant resolves to a scoped pool. InventoryRepository keeps discovery replacement and node retention with device-scoped concurrency. JobRepository owns submit, claim, renew, complete and defer; claims assign a lease token, worker identity and expiry, and every later update requires the token. Migrations are versioned, locked, checksummed and fail-closed.
    • Web, API and CLI: routes, tenant paths, /api/v1, OpenAPI, AJAX shapes, static paths and health and metrics paths are preserved. Every write carries an audited Actor. API requests never authenticate with browser sessions. Forwarded identity is accepted only from configured proxy CIDRs. The CLI uses the same application commands.
    • Workers and extensions: typed ActionPlan phases check, early, main, user, store, late, ordered by extension ID and priority. Credentials are filtered before transport use and supplied through approved executables, argument vectors or a provider protocol, never shell interpolation. Adapters cover SNMP, SSH/CLI, HTTP, DNS and hooks. Third-party behaviour runs as signed, versioned WASI components with declared capabilities.
    • Configuration and operations: YAML deserialised into versioned structs, with JSON Schema, source-located diagnostics and effective-config output. Child processes do not inherit database passwords. tracing events and OpenTelemetry spans carry tenant, job, lease, action, device and outcome; metrics cover queue depth, claim latency, lease reclaims and migration status.
    • Testing: repository tests for tenant isolation, audit-plus-enqueue atomicity and migrations; queue tests for concurrent claims, crashes and stale workers; workflow fixtures for transports, phases, timeouts and deferral; compatibility tests for URLs, payloads, roles and sessions; extension and operational tests. Existing CI covers none of these fully.

    Data And Runtime Constraints

    • Shared datastore: PostgreSQL is the transactional system of record and durable queue; external databases are read-only reporting adapters.
    • Migrations: temporary Rust-owned writes need an additive receipt store and fail-closed migrations; the target uses versioned, checksummed, locked SQL migrations.
    • Process topology: coexistence keeps the Perl web, producers, manager, poller and queue, with the Rust executor as a supervised local service or sidecar.
    • Deployment: the executor needs readiness checks and a fixed Unix-socket path. netdisco-docker sits outside the repository and follows mutable master, so topology must not prescribe Kubernetes, Compose or service units.
    • CI: Bootstrap adds Rust formatting, Clippy, protocol, schema and bridge tests alongside the Perl suite.
    • Observability: bridge telemetry excludes credentials, secrets and full configuration.
    • Operational: force-legacy rollback is configuration only; post-accept failures never retry through Perl; no schema-down during incidents.
    • Ownership: the project owns the workspace, migrations, config schema, actions, extensions, API spec, fixtures and OCI inputs. The platform supplies PostgreSQL operations, backups, secrets, ingress and TLS, trusted proxies, device reachability and deployment.

    Risks And Follow-Ups

    RiskImpactEvidenceMitigation Or Follow-Up
    Rust accepts work but the response is lost or malformed.A Perl retry could duplicate device operations or writes.Bridge rollback rulesReceipts, recoverable marking, reconciliation; never fall back after acceptance.
    Queue updates are not lease-safe.A stale worker may change a reclaimed job.lib/App/Netdisco/JobQueue/PostgreSQL.pm:209, :222, :253Perl keeps queue ownership; lease tokens in the target.
    Rust and Perl write the same aggregate.Conflicting state or duplicate writes.Data-ownership rulesDeclared route ownership; Rust writes aggregates and receipt in one transaction.
    Incomplete Rust behaviour skips phases or outcome mapping.Worker behaviour changes silently.lib/App/Netdisco/Worker/Runner.pm:25, :78; lib/App/Netdisco/Worker/Loader.pm:73Compatibility manifests, offline replay, phase evidence, outcome comparison.
    Configuration enables an unknown route.Work routed without an implementation.Empty route registryReject unknown registrations; legacy by default.
    Receipt schema applied through fail-open migrations.Rust writes may lack recovery records.lib/App/Netdisco/DB/SchemaVersioned.pm:11; bin/netdisco-db-deploy:147Fail-closed migrations and backup and restore rehearsal first.
    Observe mode treated as live dual execution.Duplicate device operations or writes.Observe mode definitionObserve records a sanitised envelope only; parity uses offline replay.
    Secrets or broad configuration cross the bridge.Credentials leak to Rust or extensions.lib/App/Netdisco/Transport/Python.pm:75Send bounded configuration and credential capabilities only.
    Mutable external deployment input.Deployed executor may not match tested code..github/workflows/build-and-publish.yml:144Pin to an immutable revision; assign ownership.
    Unclear platform deployment ownership.The repository cannot package and operate the executor alone.netdisco-docker boundaryResolve before production enablement.
    Discovery relies on broad locking.Looser concurrency could change snapshots or node retention.lib/App/Netdisco/Worker/Plugin/Discover/Properties.pm:520Document device-scoped concurrency; prove equivalent atomicity.
    Dynamic plugin loading hides coupling.Load order or available behaviour could change.lib/App/Netdisco/Web.pm:144Explicit manifests, registration and WASI contracts, gradually.
    Public web and API change during worker migration.Browsers, automation and integrations break.Existing web boundaryKeep web, API, auth, routes, sessions, templates, assets and proxy trust unchanged in the bridge phase.
  3. Standardise

    Migration Guide

    Perl has quirks. So does Rust. For each category of code, patterns and data structures, agents write a list of legacy and target patterns, and we synthesise them into one guide every implementor uses.

    Migration Guide

    Existing PatternTarget PatternApplies WhenAvoidEvidence
    PostgreSQL is the tenant-selected authority for inventory, jobs, audits, users, sessions and deferrals; external databases are separate GenericDB sources.Tenant-scoped PostgreSQL repositories as the transactional authority; external sources as parameterised, read-only adapters.Application-state reads and writes; external reporting queries.Default-database access, cross-tenant joins, authoritative caches, writes to reporting stores.lib/App/Netdisco/Configuration.pm:71; lib/App/Netdisco/Configuration.pm:86; lib/App/Netdisco/DB.pm:14
    Device identity is an IP, device-port identity (port, ip), node identity (mac, switch, port, vlan); nodes can outlive a removed port.Validated domain keys with the same PostgreSQL shapes and retention.Inventory, discovery, topology, public identifiers.Unmapped surrogate keys, globally unique port assumptions, cascading node deletion.lib/App/Netdisco/DB/Result/Device.pm:13; lib/App/Netdisco/DB/Result/DevicePort.pm:79; lib/App/Netdisco/DB/Result/Node.pm:47
    Discovery replaces a full port collection under ACCESS EXCLUSIVE, keeps permitted custom fields and retains nodes.Atomic device-port replacement with a documented device-scoped concurrency guard, node retention and configured-field filtering.Discovery and complete inventory writes.Incremental snapshots, partial visibility, dropped nodes, weaker concurrency without equivalent semantics.lib/App/Netdisco/Worker/Plugin/Discover/Properties.pm:520; lib/App/Netdisco/DB/ExplicitLocking.pm:28
    Migrations can swallow SQL errors while advancing schema version metadata.Exclusive-lock, versioned, checksummed, fail-closed migrations; record success only after commit.Rust-owned schema changes and receipt storage.Treating caught SQL errors as success; unchecked drift.lib/App/Netdisco/DB/SchemaVersioned.pm:11; bin/netdisco-db-deploy:147

    Migration Guide

    How To Use This Guide

    Implementation agents use only the mappings that apply to their assigned change. Each row maps a Perl pattern in Netdisco to the Rust pattern that replaces it, says when it applies, names what to avoid, and cites the repository lines it rests on.

    The original guide holds 36 mappings. The 14 below carry the queue, data, execution and security contracts that most slices touch; the rest are listed in short form after the tables.

    Diagram: How To Use This GuideDiagram: How To Use This Guide

    Pattern Mapping

    Data and persistence

    PostgreSQL stays the authority. These rows keep its keys, locks and migration behaviour intact.

    Existing PatternTarget PatternApplies WhenAvoidEvidence
    PostgreSQL is the tenant-selected authority for inventory, jobs, audits, users, sessions and deferrals; external databases are separate GenericDB sources.Tenant-scoped PostgreSQL repositories as the transactional authority; external sources as parameterised, read-only adapters.Application-state reads and writes; external reporting queries.Default-database access, cross-tenant joins, authoritative caches, writes to reporting stores.lib/App/Netdisco/Configuration.pm:71; lib/App/Netdisco/Configuration.pm:86; lib/App/Netdisco/DB.pm:14
    Device identity is an IP, device-port identity (port, ip), node identity (mac, switch, port, vlan); nodes can outlive a removed port.Validated domain keys with the same PostgreSQL shapes and retention.Inventory, discovery, topology, public identifiers.Unmapped surrogate keys, globally unique port assumptions, cascading node deletion.lib/App/Netdisco/DB/Result/Device.pm:13; lib/App/Netdisco/DB/Result/DevicePort.pm:79; lib/App/Netdisco/DB/Result/Node.pm:47
    Discovery replaces a full port collection under ACCESS EXCLUSIVE, keeps permitted custom fields and retains nodes.Atomic device-port replacement with a documented device-scoped concurrency guard, node retention and configured-field filtering.Discovery and complete inventory writes.Incremental snapshots, partial visibility, dropped nodes, weaker concurrency without equivalent semantics.lib/App/Netdisco/Worker/Plugin/Discover/Properties.pm:520; lib/App/Netdisco/DB/ExplicitLocking.pm:28
    Migrations can swallow SQL errors while advancing schema version metadata.Exclusive-lock, versioned, checksummed, fail-closed migrations; record success only after commit.Rust-owned schema changes and receipt storage.Treating caught SQL errors as success; unchecked drift.lib/App/Netdisco/DB/SchemaVersioned.pm:11; bin/netdisco-db-deploy:147
    Queue and execution

    The Perl poller keeps the queue. Rust receives an immutable request and returns a bounded outcome.

    Existing PatternTarget PatternApplies WhenAvoidEvidence
    A queued job is booked conditionally, but deferral and terminal updates use only the job ID.Claim with an opaque lease token, worker identity and expiry; require the lease for renewal, deferral and completion.Claims, reclaiming, deferrals, terminal updates.ID-only claims, backend as a route selector, stale-worker updates after re-lease.lib/App/Netdisco/JobQueue/PostgreSQL.pm:170; lib/App/Netdisco/JobQueue/PostgreSQL.pm:222; lib/App/Netdisco/JobQueue/PostgreSQL.pm:262
    Deferral requeues the job and updates device_skip; completion resets deferrals in an exclusive transaction.Job state and device deferral as one atomic, lease-protected operation.Connection or authorisation deferrals; terminal device results.Terminalising a deferral, changing only one state, resetting outside the transaction.lib/App/Netdisco/JobQueue/PostgreSQL.pm:194; lib/App/Netdisco/JobQueue/PostgreSQL.pm:242; lib/App/Netdisco/Worker/Status.pm:19
    MCE pollers dequeue work, run the Perl runner and turn defer or terminal status into queue updates.Perl poller stays queue and status owner; a versioned Worker Execution Bridge sits before the runner; Rust writes only declared aggregates and the receipt.Claimed jobs during mixed Perl/Rust operation.Direct Rust queue updates, independent consumers, dual execution.bin/netdisco-backend-fg:49; lib/App/Netdisco/Backend/Role/Poller.pm:30; lib/App/Netdisco/Backend/Role/Poller.pm:53
    A mutable Perl job carries queue data, device, phase, statuses and execution state.An immutable, versioned WorkerExecutionRequest with JobIdentity, TenantId, ActionRef, device and port IDs, Actor, Deadline, CredentialCapability; phase-local ActionContext.Poller-to-executor dispatch.Serialised Perl objects, unbounded hashes, process globals, executor mutation of Perl-owned fields.lib/App/Netdisco/Backend/Job.pm:9; lib/App/Netdisco/Worker/Runner.pm:25
    Status is mutable and the result is inferred from ordered strings: error > done > defer > info.Closed WorkerOutcome { Done, Info, Defer, Error }, bounded logs, explicit phase names, one pure outcome reducer.Action pipelines and bridge response conversion.Stringly typed outcomes, implicit ordering, panics for expected failures.lib/App/Netdisco/Worker/Status.pm:11; lib/App/Netdisco/Worker/Status.pm:59; lib/App/Netdisco/Backend/Job.pm:62
    Extensions and integrations

    Dynamic loading and shared global state give way to declared plans and capabilities.

    Existing PatternTarget PatternApplies WhenAvoidEvidence
    Dynamically loaded worker plugins run check → early → main → user → store → late by namespace and priority; Python worklets may join.Static action registration and versioned extension manifests compiled into a deterministic ActionPlan.Routed worker actions and extension migration.Arbitrary module paths, load-order dependence, main-only actions, unmanifested plugins.lib/App/Netdisco/Worker/Loader.pm:20; lib/App/Netdisco/Worker/Loader.pm:73; lib/App/Netdisco/Worker/Runner.pm:78
    Credentials are ACL-filtered into mutable global settings; external lookup interpolates values into a shell command.Least-privilege credential capabilities resolved before dispatch, tied to tenant, action, device, mode and deadline; provider protocol or validated argument vector.Rust device routes; SNMP, SSH and HTTP adapters.Raw secrets in context, global credential state, secrets in receipts, shell interpolation.lib/App/Netdisco/Worker/Runner.pm:45; lib/App/Netdisco/Util/DeviceAuth.pm:126; lib/App/Netdisco/Util/DeviceAuth.pm:136
    Python worklets use a persistent subprocess fed through environment and temporary files.Keep a compatibility subprocess adapter; replacement extensions use a versioned WASI ABI with explicit capabilities.Existing worklets and future third-party extensions.Broad environment protocols, arbitrary stash keys, ambient filesystem or network access.share/config.yml:838; lib/App/Netdisco/Transport/Python.pm:36; lib/App/Netdisco/Transport/Python.pm:75
    Web and identity

    The edge keeps authentication authority; migrated handlers receive an actor.

    Existing PatternTarget PatternApplies WhenAvoidEvidence
    Port control is an AJAX write that creates an audit row and a durable job in one tenant transaction, with username, source IP and {} JSON success.One application command persisting audit and submission together with an audited Actor, same validation and response.Attributable write-plus-async operations; port-control migration.Separate commits, enqueue before audit, lost attribution, synchronous execution.lib/App/Netdisco/Web/PortControl.pm:10; lib/App/Netdisco/Web/PortControl.pm:32; lib/App/Netdisco/Web/PortControl.pm:50
    Browser requests use sessions; API requests clear sessions and use tokens; delegated identity may come from remote-user headers.Session issuance and authentication stay at the compatibility edge; migrated handlers receive the actor and auth mode.Protected routes, login and logout, writes.Browser sessions as API credentials, untrusted identity headers, altered token issuance.lib/App/Netdisco/Web/AuthN.pm:25; lib/App/Netdisco/Web/AuthN.pm:66; lib/App/Netdisco/Web/AuthN.pm:208
    Further mappings in the original

    Data, queue and execution

    • Durable admin job rows → JobRepository as sole queue owner with recognisable fields. lib/App/Netdisco/DB/Result/Admin.pm:10
    • Rust-routed state changes → declared aggregates plus a tenant-scoped receipt in one transaction. lib/App/Netdisco/Worker/Runner.pm:78
    • Named table locks and SELECT … FOR UPDATE → transaction-scoped locks in repository contracts. lib/App/Netdisco/DB/ExplicitLocking.pm:28
    • UTF-8 JSONB custom fields → typed JSON values, jsonb_set partial updates. lib/App/Netdisco/JobQueue/PostgreSQL.pm:376
    • Exceptions and timeout_call → Result<T, ActionError> with a propagated deadline. lib/App/Netdisco/Worker/Runner.pm:36
    • Mutable Dancer configuration → versioned structs validated to EffectiveConfig. lib/App/Netdisco/Configuration.pm:31
    • netdisco-do --enqueue and scheduler producers → unchanged; inline CLI stays Perl. bin/netdisco-do:192
    • Worker logs and job status → structured bridge telemetry keyed by tenant, job and dispatch. lib/App/Netdisco/Backend/Role/Poller.pm:31
    • Perl modules across Dancer, DBIx::Class and workers → modular-monolith crate boundaries. Build.PL:25

    Web and API

    • One Dancer process for all routes → explicit route-family dispatcher; unregistered paths stay with Dancer. lib/App/Netdisco/Web.pm:122
    • API classification by /api/ and Accept → one shared classification adapter. lib/App/Netdisco/Util/Web.pm:42
    • Shared JSON errors and Swagger → endpoint-specific adapters preserving shape and status. lib/App/Netdisco/Web/API/Objects.pm:17
    • Template Toolkit pages with AJAX fragments → one page-family contract. lib/App/Netdisco/Web/Search.pm:25
    • /t/$tenant URLs → tenant resolved before dispatch, same logical path for both handlers. lib/App/Netdisco/Web.pm:62
    • Plack proxy, TLS, header and cache middleware → kept outside the route-family switch. bin/netdisco-web-fg:32
    • Authorisation failures rendered per JSON, XHR or browser → centralised rendering. lib/App/Netdisco/Web/AuthN.pm:223
    • Stable static paths and dynamic plugin assets → preserved URLs and an explicit asset manifest. lib/App/Netdisco/Web/Static.pm:6
    • Configured health and metrics paths → separate operational route families. lib/App/Netdisco/Web/Metrics.pm:25
    • Dynamically loaded site plugins and reports → kept on the legacy side; manifests for replacements. lib/App/Netdisco/Web.pm:153

    Integrations

    • SNMP, SSH/CLI and HTTP inside Perl plugins → typed transport adapters behind the worker boundary. lib/App/Netdisco/Worker/Runner.pm:92
    • Optional graph, visualisation and native SNMP integrations → kept available through adapters. Build.PL:125

    Unresolved Pattern Questions

    • None. The source guide recorded no unresolved questions.
  4. Bootstrap

    Migration architecture

    An agent takes the earlier artefacts and the code, plans the seam, implements it and opens a change request. We merged the first attempt, then reverted it. On 7 Aug we merged the Worker Execution Bridge (#42): one seam in the Perl runner that every migrated action crosses, with Perl as the fallback and the rollback path (workers.execution_bridge.force_legacy).

    1. First architecture, #1 #3 #12, merged then reverted
    2. Bridge #42
    3. dumpconfig #60
    4. Blocked draft #98
    5. getapikey #100, tastyjobs #101
    6. expirenodes #103
  5. Prioritise

    Slice Backlog

    One agent identifies slices and another ranks them. We start easy, to show value early. dumpconfig headed the backlog; today it holds 58 slice contracts.

    Bosun's slice map for Netdisco: 54 planned, 1 verifying, 3 of 58 complete, in five waves.Bosun's slice map for Netdisco: 54 planned, 1 verifying, 3 of 58 complete, in five waves.
  6. Migrate

    A migrated slice

    We start with a specification and enrich it with Parity proof of the legacy behaviour. Agents cut the specification into smaller tasks and implement them in sequence; a final check, deterministic and qualitative, gets one chance to fix before the change request is created.

One slice, start to pull request

A run is one workflow executed once, and Bosun records every step: what the agent did, how long it took, what it produced. This is the getapikey run, drawn from those records.

getapikey, #100, against the blocked #98
proof and validation
3h43m
Rust implementation
45m
specify
10m
setup
12m
pull request
3m
total
4h54m
  1. Ensure verification environment
  2. Build Parity
  3. Specify slice
  4. Add Parity proof
  5. Challenge and strengthen proof
  6. Split implementation
  7. Implement work units
  8. Source Integration Test
  9. Target E2E tests
  10. Integrate and fix slice
  11. Challenge proof and review slice
  12. Create draft pull request
#98 blocked

Parity is recorded, not claimed.

In a legacy system we want the new system to behave the same way on the outside as the old one. Measuring that by hand takes a lot of boilerplate, and GenAI's habit of being confidently wrong makes it harder. We need to verify that the intent of the legacy system matches the new one.

Parity allows you to define expectations on how the legacy and new system must align. It captures behaviour on a syscall level and replays behaviour in the legacy system, identifies variance, and records the result. That same recording is then compared against the migrated slice. You write only the proof. We are still developing and experimenting with the language and the tooling.

  1. Same input One example, or N generated cases
  2. Production entrypoint netdisco-do tastyjobs
  3. Legacy Perl worker

    • result
    • files
    • SQL
    • HTTP
    • command
    • processes
    • sockets
    • file reads and changes
    • final filesystem and PostgreSQL state

    Target Rust worker, through the bridge

    • result
    • files
    • SQL
    • HTTP
    • command
    • processes
    • sockets
    • file reads and changes
    • final filesystem and PostgreSQL state
  4. Compared
    Exact equality by default
    • differs
    • exclude
    • forbid
    • require target
  5. Verdict
    • exit 0 pass
    • 1 mismatch
    • 2 error
Run the same action. Compare what changes.
Parity proof, tastyjobs
proof tastyjobs.default {
  @tasty_native
example absent { fixture = { count = 23 } }example empty { fixture = { count = 23, limit = "" } }example zero { fixture = { count = 23, limit = "0" } }  given tastyjobs_prepare(fixture)
  when tastyjobs(fixture)
  @tasty_done
then { assert views.stderr.added.filter(line, line.line.contains("job_priority")).size() == 20 }}
@tasty_native, @tasty_done
@tasty_native = {
  map observer_output {
    legacy {
notices = views.stderr.added.filter(line, line.line.startsWith("NOTICE:  parity_tastyjobs_write "))into [{"observer_only": notices.all(line, line.line.startsWith("NOTICE:  parity_tastyjobs_write "))}]    }
    target {
notices = views.stderr.added.filter(line, line.line.startsWith("NOTICE:  parity_tastyjobs_write "))into [{"observer_only": notices.all(line, line.line.startsWith("NOTICE:  parity_tastyjobs_write "))}]    }
} because "DBD::Pg prints fixture observer notices whereas rust-postgres does not; consume only those injected lines, whose full payloads are compared in preview_effects; all application output remains compared"  exclude views.postgres
    where view.phase == "when"
&& !(view.request.sql in ["ROLLBACK", "rollback"]|| (view.response.messages.size() > 0 && view.response.messages[0].type == "notice" && view.response.messages[0].fields.message.startsWith("parity_tastyjobs_write ")))because "DBIC/native SQL projections and connection ordering differ; write notices and rollback responses remain available to the identity mapping, alongside full output and database state"  map preview_effects {
    legacy {
writes = views.postgres.added.filter(call, call.phase == "when" && call.response.messages.size() > 0 && call.response.messages[0].type == "notice" && call.response.messages[0].fields.message.startsWith("parity_tastyjobs_write "))rollbacks = views.postgres.added.filter(call, call.phase == "when" && call.request.sql in ["ROLLBACK", "rollback"])assert writes.all(call, call.response.messages[0].type == "notice" && call.response.messages[0].fields.message.startsWith("parity_tastyjobs_write "))assert rollbacks.all(call, call.response.messages[0].tag == "ROLLBACK")into [{"writes": writes.map(call, call.response.messages[0].fields.message), "rollbacks": rollbacks.map(call, call.response.messages[0].tag)}]    }
    target {
writes = views.postgres.added.filter(call, call.phase == "when" && call.response.messages.size() > 0 && call.response.messages[0].type == "notice" && call.response.messages[0].fields.message.startsWith("parity_tastyjobs_write "))rollbacks = views.postgres.added.filter(call, call.phase == "when" && call.request.sql in ["ROLLBACK", "rollback"])assert writes.all(call, call.response.messages[0].type == "notice" && call.response.messages[0].fields.message.startsWith("parity_tastyjobs_write "))assert rollbacks.all(call, call.response.messages[0].tag == "ROLLBACK")into [{"writes": writes.map(call, call.response.messages[0].fields.message), "rollbacks": rollbacks.map(call, call.response.messages[0].tag)}]    }
} because "fixture statement triggers observe actual Admin and DeviceSkip identities and values before rollback; compare notices and rollback outcomes rather than SQL spelling or affected counts alone"  require target views.local_ipc
where view.resource == "unix+stream:///tmp/netdisco-parity-worker.sock"      && view.phase == "when"
&& view.request.participants.exists(p, p.written.size > 0 && p.read.size > 0)because "tastyjobs must be answered by the production Rust executor, not Perl fallback"  forbid target views.process
where view.actor.endsWith("/nd-worker-executor")because "the Rust executor must not spawn legacy or another answer-producing process"differs at views.stdout.added.*.line on lines matching "^[{]\"legacy_workers\":(?:true|false)[}]$"because "legacy runs its phases; a native target must not run the legacy callback"  then {
assert views.stdout.added.exists(line, line.line == (subject == "legacy" ? '{"legacy_workers":true}' : '{"legacy_workers":false}'))  }
}
@tasty_done = {
  then {
    assert result.exit_code == 0
assert views.stdout.added.exists(line, line.line == '{"log":"Showed the tastiest jobs","status":"done"}')assert views.tasty_completion.added.size() == 1 && views.tasty_completion.removed.size() == 1assert views.tasty_completion.added[0].row.contains('"status":"done"')assert views.tasty_completion.added[0].row.contains('Showed the tastiest jobs')assert views.stdout.added.exists(line, line.line == '{"preview_unchanged":true}')  }
}

The proof asserts 7 surfaces. Parity recorded 71 SQL statements, 10 file reads and 2 processes on each side, and compared those too.

tastyjobs.default [zero]

run
38424924801
pr
#101
head
3ab36a8cd7
schema
0.2.5
Observed Perl (legacy)Rust (target)

In the proof

Exit status tastyjobs.result assert exit 0
Worker socket views.local_ipc require —
SQL traffic views.postgres excludemap +71 −0
Processes views.process forbid +2 −0
Standard error views.stderr assertmap +363 −0 +343 −0
Standard output views.stdout assertdiffers +3 −0
Job completion views.tasty_completion assert +1 −1

Recorded without being asked

Show all 7 rows
SQL statements views.postgres
71 statements
  • SELECT 2 · INSERT 24 · UPDATE 20 · DELETE 24 · ROLLBACK 1
  • DELETE FROM admin WHERE ( device IN ( SELECT me.ip FROM device me ) )
  • DELETE FROM admin
  • DELETE FROM community WHERE ( ip IN ( SELECT me.ip FROM device me ) )
  • DELETE FROM device_browser WHERE ( ip IN ( SELECT me.ip FROM device me ) )
  • DELETE FROM device_ip WHERE ( ip IN ( SELECT me.ip FROM device me ) )
  • DELETE FROM device_module WHERE ( ip IN ( SELECT me.ip FROM device me ) )
Database relations views.postgres_state
138 observed, 6 changed
  • public.admin 1 → 24 rows
  • public.device 1 → 0 rows
  • public.device_ip 1 → 0 rows
  • public.admin 1 → 24 rows
  • public.device 1 → 0 rows
  • public.device_ip 1 → 0 rows
Files read views.filesystem
10 files
  • /etc/gnutls/config 119 B
  • /etc/host.conf 92 B
  • /etc/hosts 325 B
  • /etc/nsswitch.conf 526 B
  • /etc/passwd 2.1 KB
  • /etc/perl/Net/libnet.cfg 611 B
  • /etc/protocols 3.1 KB
  • /etc/shadow 1.1 KB
  • /etc/ssl/openssl.cnf 12.0 KB
  • /run/systemd/resolve/stub-resolv.conf 970 B
Processes spawned views.process
2 processes
  • uname -n exit 0
  • uname -n exit 0
Process output views.process_io 28 B written, 0 B read
Audit audit envelope
3 modules, 5 of 15 parsers, settled
  • modules: filesystem, network, process
  • parsers used: filesystem, postgres, postgres_state, process, process_io
Excluded, with reason exclusions
3 exclusions
  • DBIC/native SQL projections and connection ordering differ; write notices and rollback responses remain available to the identity mapping, alongside full output and database state ×14
  • command input and result traffic is proof plumbing ×4
  • runtime support files are outside migrated behavior ×501
5 exclusions
  • DBIC/native SQL projections and connection ordering differ; write notices and rollback responses remain available to the identity mapping, alongside full output and database state ×15
  • command input and result traffic is proof plumbing ×4
  • runtime support files are outside migrated behavior ×501
  • temporary filesystem activity is outside migrated behavior ×1
  • the worker transport is internal; caller output and effects remain compared ×1
PASS tastyjobs.default (3 cases) | MATCH tastyjobs.result, tastyjobs_prepare.result, views.admin_queue, +9 more (all 3 cases of the proof) Perl side from Parity's legacy cache; Rust ran fresh in CI.

The following is an overview of all proofs Bosun generated for each slice and requirement. Proofs are small, and easy to read, and review. When run, we can trust it captures all observed behaviour, any difference must be explicit. This allows us to move fast in review and merge with confidence. All boxes below are interactable.

getapikey 21 proofs / 39 cases

tastyjobs 23 proofs / 48 cases

expirenodes 29 proofs / 55 cases

  • example generated
  • require target 76
  • forbid 72
  • differs 70
  • exclude 76
  • generate 1
All 161 cases pass on the merged head.

An operator requests an absent key

dumpconfig1 caserequire target parity/dumpconfig-worker-action.md

proof dumpconfig.absent-key {
  require target views.local_ipc
where view.resource == "unix+stream:///tmp/netdisco-parity-worker.sock"because "this case must reach the Rust worker rather than use Perl fallback"example absent_in_limit_key { selection = "parity_absent_key" }  when dumpconfig(selection)
  then {
assert views.admin_queue.added.size() == 0 && views.admin_queue.removed.size() == 0    assert result.exit_code == 0
assert views.stdout.added.size() == 0assert views.stderr.added.size() == 1 && views.stderr.added[0].line == "undef" && views.stderr.added[0].ending == "\n"  }
}

An operator requests all configuration

dumpconfig1 caserequire target parity/dumpconfig-worker-action.md

proof dumpconfig.all-configuration {
  require target views.local_ipc
where view.resource == "unix+stream:///tmp/netdisco-parity-worker.sock"because "this case must reach the Rust worker rather than use Perl fallback"example complete { selection = "" }  when dumpconfig(selection)
  then {
assert views.admin_queue.added.size() == 0 && views.admin_queue.removed.size() == 0    assert result.exit_code == 0
assert views.stdout.added.size() == 0assert views.stderr.added.size() > 300  }
}

Complete configuration cannot be resolved

dumpconfig1 casediffersexclude parity/dumpconfig-worker-action.md

proof dumpconfig.complete-configuration-required {// The live configuration carries a value legacy Data::Printer output// cannot be reproduced from. Capture carries it as an explicit node and the// native action refuses the whole dump before rendering; no executor test// feature participates. The legacy subject has no executor, takes the// pre-connect fallback, and dumps the value as it always did — capture runs// only after the executor answers, so removing the executor restores exact  // legacy behavior.
  exclude legacy views.stderr
because "legacy prints the dump directly while the target refuses to render; the assertions pin the dump's size on legacy and silence on the target"example complete_configuration_unavailable { fault = "complete-configuration"  }
  when dumpconfig_fault(fault)
  then {
assert views.admin_queue.added.size() == 0 && views.admin_queue.removed.size() == 0    assert result.exit_code == 0
assert subject != "target" || views.stderr.added.size() == 0assert subject != "target" || (views.stdout.added.size() == 1 && views.stdout.added[0].line == "{\"log\":\"configuration_unrepresentable\",\"status\":\"error\"}" && views.stdout.added[0].ending == "\n")assert subject != "legacy" || views.stderr.added.size() > 300assert subject != "legacy" || (views.stdout.added.size() == 1 && views.stdout.added[0].line == "{\"log\":\"Dumped config\",\"status\":\"done\"}" && views.stdout.added[0].ending == "\n")  }
differs at views.stdout.added.*.line where legacy.contains('"status":"done"') && target.contains('"status":"error"')because "the target fails closed on the unrepresentable value while legacy dumps it"}

Deviceless dumps always render an empty device_auth

dumpconfig1 caserequire target parity/dumpconfig-worker-action.md

proof dumpconfig.device-auth-reduction {  require target views.local_ipc
where view.resource == "unix+stream:///tmp/netdisco-parity-worker.sock"because "this case must reach the Rust worker rather than use Perl fallback"example deviceless { selection = "device_auth" }  when dumpconfig(selection)
  then {
assert views.admin_queue.added.size() == 0 && views.admin_queue.removed.size() == 0    assert result.exit_code == 0
assert views.stderr.added.size() == 1 && views.stderr.added[0].line == "[]" && views.stderr.added[0].ending == "\n"  }
}

An operator requests one configured key

dumpconfig1 caserequire target parity/dumpconfig-worker-action.md

proof dumpconfig.existing-key {
  require target views.local_ipc
where view.resource == "unix+stream:///tmp/netdisco-parity-worker.sock"because "this case must reach the Rust worker rather than use Perl fallback"example configured_key { selection = "a_parity_selected" }  when dumpconfig(selection)
  then {
assert views.admin_queue.added.size() == 0 && views.admin_queue.removed.size() == 0    assert result.exit_code == 0
assert views.stdout.added.size() == 0assert views.stderr.added.exists(line, line.line.contains("selected-value"))  }
}

An operator requests the Perl-false selector

dumpconfig1 caserequire target parity/dumpconfig-worker-action.md

proof dumpconfig.false-string-selects-complete {  require target views.local_ipc
where view.resource == "unix+stream:///tmp/netdisco-parity-worker.sock"because "this case must reach the Rust worker rather than use Perl fallback"example false_string { selection = "0" }  when dumpconfig(selection)
  then {
assert views.admin_queue.added.size() == 0 && views.admin_queue.removed.size() == 0    assert result.exit_code == 0
assert views.stderr.added.size() > 300  }
}

Perl-falsy ambient values mean off

dumpconfig1 caserequire target parity/dumpconfig-worker-action.md

proof dumpconfig.falsy-ambient-off {
  require target views.local_ipc
where view.resource == "unix+stream:///tmp/netdisco-parity-worker.sock"because "this case must reach the Rust worker rather than use Perl fallback"// ND2_WORKER_ROLL_CALL='' and workers.BACKEND='' are the natural "off"// spellings; legacy treats both as unset (truthiness), so the dispatch// dumps fully and the resolver still publishes the backend name.example empty_string_off { fault = "falsy-ambient" }  when dumpconfig_fault(fault)
  then {
assert views.admin_queue.added.size() == 0 && views.admin_queue.removed.size() == 0    assert result.exit_code == 0
assert views.stdout.added.exists(line, line.line.contains("\"status\":\"done\""))assert views.stderr.added.exists(line, line.line.contains("BACKEND"))  }
}

Digit-bearing and dotted keys keep legacy's natural order

dumpconfig1 caserequire target parity/dumpconfig-worker-action.md

proof dumpconfig.natural-key-order {
  require target views.local_ipc
where view.resource == "unix+stream:///tmp/netdisco-parity-worker.sock"because "this case must reach the Rust worker rather than use Perl fallback"example digit_runs { selection = "a_parity_natural_order" }  when dumpconfig(selection)
  then {
assert views.admin_queue.added.size() == 0 && views.admin_queue.removed.size() == 0    assert result.exit_code == 0
assert views.stderr.added.size() > 0  }
}

Numeric string spellings match Perl numification exactly

dumpconfig1 caserequire target parity/dumpconfig-worker-action.md

proof dumpconfig.number-spellings {
  require target views.local_ipc
where view.resource == "unix+stream:///tmp/netdisco-parity-worker.sock"because "this case must reach the Rust worker rather than use Perl fallback"example flip_corpus { selection = "a_parity_number_spellings" }  when dumpconfig(selection)
  then {
assert views.admin_queue.added.size() == 0 && views.admin_queue.removed.size() == 0    assert result.exit_code == 0
assert views.stderr.added.size() > 0  }
}

An operator selects the URI::Based url_base

dumpconfig1 caserequire target parity/dumpconfig-worker-action.md

proof dumpconfig.opaque-url-base {
  require target views.local_ipc
where view.resource == "unix+stream:///tmp/netdisco-parity-worker.sock"because "this case must reach the Rust worker rather than use Perl fallback"example url_base { selection = "url_base" }  when dumpconfig(selection)
  then {
assert views.admin_queue.added.size() == 0 && views.admin_queue.removed.size() == 0    assert result.exit_code == 0
assert views.stdout.added.size() == 0assert views.stderr.added.exists(line, line.line.contains("URI::Based"))  }
}

The output sink is unavailable

dumpconfig1 caserequire targetdiffersexclude parity/dumpconfig-worker-action.md

proof dumpconfig.output-channel-required {  exclude target views.process_io
where view.resource == "process://pipe" && view.request.participants.size() == 1&& view.request.participants[0].read.size == 0&& view.request.participants[0].written.size == 74&& view.request.participants[0].written.sha256 == "f80d68897673eb85ad58dbbcfe74e0f7d7c3d611e9b86de6a84b85ef738dc5aa"because "legacy has no executor stream to reset when its direct-print sink is rejected; the target's runner error, silence and unchanged database remain asserted"  require target views.local_ipc
where view.resource == "unix+stream:///tmp/netdisco-parity-worker.sock"because "this case must reach the Rust worker rather than use Perl fallback"// The real Runner receives a poller-owned sink that explicitly rejects the// first streamed chunk. Legacy has no executor connection and keeps its// direct print behavior; the target must report the bridge failure instead.  exclude legacy views.stderr
because "legacy prints directly while the target rejects the stream; the assertions pin the dump's size on legacy and silence on the target"example output_channel_unavailable { fault = "output-sink" }  when dumpconfig_fault(fault)
  then {
assert views.admin_queue.added.size() == 0 && views.admin_queue.removed.size() == 0    assert result.exit_code == 0
assert subject != "target" || views.stderr.added.size() == 0assert subject != "target" || views.stdout.added.exists(line, line.line.contains("worker execution output sink rejected a chunk"))assert subject != "target" || views.stdout.added.exists(line, line.line.contains("\"status\":\"error\""))assert subject != "legacy" || (views.stdout.added.size() == 1 && views.stdout.added[0].line == "{\"log\":\"Dumped config\",\"status\":\"done\"}" && views.stdout.added[0].ending == "\n")assert subject != "legacy" || views.stderr.added.size() > 300  }
differs at views.stdout.added.*.line where legacy.contains('"status":"done"') && target.contains('"status":"error"')because "the target reports the rejected required poller sink"}

Removing the executor restores legacy even for rejectable configurations

dumpconfig1 case parity/dumpconfig-worker-action.md

proof dumpconfig.rollback-before-context {// The fault mode points the bridge at an absent socket and adds an// oversized deferrable_actions entry, which the snapshot carries like any// other value. With no executor the dispatch must dump on both legs.example oversize_ambient { fault = "context-executor-down" }  when dumpconfig_fault(fault)
  then {
assert views.admin_queue.added.size() == 0 && views.admin_queue.removed.size() == 0    assert result.exit_code == 0
assert views.stdout.added.exists(line, line.line.contains("\"status\":\"done\""))assert views.stderr.added.size() > 300  }
}

A shared reference renders as legacy's back-reference

dumpconfig1 caserequire target parity/dumpconfig-worker-action.md

proof dumpconfig.shared-reference-backref {  require target views.local_ipc
where view.resource == "unix+stream:///tmp/netdisco-parity-worker.sock"because "this case must reach the Rust worker rather than use Perl fallback"example yaml_alias { selection = "a_parity_shared" }  when dumpconfig(selection)
  then {
assert views.admin_queue.added.size() == 0 && views.admin_queue.removed.size() == 0    assert result.exit_code == 0
assert views.stderr.added.exists(line, line.line.contains("var{"))  }
}

Wide arrays keep legacy's nested padding

dumpconfig1 caserequire target parity/dumpconfig-worker-action.md

proof dumpconfig.wide-array-padding {  require target views.local_ipc
where view.resource == "unix+stream:///tmp/netdisco-parity-worker.sock"because "this case must reach the Rust worker rather than use Perl fallback"example twelve_elements { selection = "a_parity_wide_array" }  when dumpconfig(selection)
  then {
assert views.admin_queue.added.size() == 0 && views.admin_queue.removed.size() == 0    assert result.exit_code == 0
assert views.stderr.added.exists(line, line.line.contains("nested-value"))  }
}

The resolved backend enters the compared surface

dumpconfig1 caserequire target parity/dumpconfig-worker-action.md

proof dumpconfig.workers-backend {
  require target views.local_ipc
where view.resource == "unix+stream:///tmp/netdisco-parity-worker.sock"because "this case must reach the Rust worker rather than use Perl fallback"example resolved { selection = "workers" }  when dumpconfig(selection)
  then {
assert views.admin_queue.added.size() == 0 && views.admin_queue.removed.size() == 0    assert result.exit_code == 0
assert views.stderr.added.exists(line, line.line.contains("BACKEND"))  }
}

An envelope the transport would refuse never reaches the worker

worker-execution-bridge1 caseforbiddiffersexclude parity/worker-execution-bridge.md

proof worker-execution-bridge.oversize-envelope {  forbid target views.local_ipc
where view.resource == "unix+stream:///tmp/netdisco-parity-worker.sock"because "an oversized field must return to legacy before the connect, so no dispatch may reach the worker at all"  exclude legacy views.stderr
    where line == "hello from Perl"
because "only the subject that owns the Perl worker prints, which is the same evidence that the fallback happened; the assertions pin each subject's exact stderr"example oversize_subaction { case = "oversize" }  when bridge_runtime(case)
  then {
assert views.admin_queue.added.size() == 0 && views.admin_queue.removed.size() == 0    assert result.exit_code == 0
assert subject != "legacy" || (views.stderr.added.size() == 1 && views.stderr.added[0].line == "hello from Perl" && views.stderr.added[0].ending == "\n")assert subject != "legacy" || (views.stdout.added.size() == 1 && views.stdout.added[0].line == "{\"log\":\"hello from Perl\",\"status\":\"done\"}" && views.stdout.added[0].ending == "\n")assert subject != "target" || views.stderr.added.size() == 0assert subject != "target" || (views.stdout.added.size() == 1 && views.stdout.added[0].line == "{\"log\":\"failed to report from any worker!\",\"status\":\"error\"}" && views.stdout.added[0].ending == "\n")  }
differs at views.stdout.added.*.linewhere legacy.contains('"status":"done"') && target.contains('"status":"error"')because "the target deliberately has no Perl implementation of this diagnostic, so its fallback reports no worker; the differing result is what proves the Perl path, not the routed path, ran"}

Worker Execution Bridge Proof

worker-execution-bridge2 casesdiffers parity/worker-execution-bridge.md

proof worker-execution-bridge.parity-hello {  example plain { extra = "" }
example with_subaction { extra = "vlan 42" }  when hello(extra)
  then {
    assert result.exit_code == 0
assert views.stdout.added.size() == 0assert subject != "legacy" || (views.stderr.added.size() == 1 && views.stderr.added[0].line == "hello from Perl" && views.stderr.added[0].ending == "\n")assert subject != "target" || (views.stderr.added.size() == 1 && views.stderr.added[0].line == "hello from Rust" && views.stderr.added[0].ending == "\n")// `netdisco-do` runs with job id 0 and never writes the admin queue, so// this holds on both sides today. It is here as a regression guard: a// change that made either side write admin would break it.assert views.admin_queue.added.size() == 0 && views.admin_queue.removed.size() == 0  }
differs at views.stderr.added.*.linewhere legacy == "hello from Perl" && target == "hello from Rust"because "the diagnostic identifies the implementation that answered"}

A routed device job completes under the canonical device key

worker-execution-bridge1 caserequire targetdiffersexclude parity/worker-execution-bridge.md

proof worker-execution-bridge.routed-device-completion {  exclude views.postgres
because "the runner's device lookup selects clock-relative ages (Device with_times), so the raw protocol rows differ between any two runs; the device_skip and admin_queue views retain the completion state this case proves"  require target views.local_ipc
where view.resource == "unix+stream:///tmp/netdisco-parity-worker.sock"because "this case must reach the Rust worker rather than use Perl fallback"example alias_ip { case = "device" }  when bridge_runtime(case)
  then {
    assert result.exit_code == 0
assert views.stdout.added.exists(line, line.line.contains("\"device\":\"192.0.2.10\""))assert views.stdout.added.exists(line, line.line.contains("\"status\":\"done\""))assert subject != "legacy" || (views.stderr.added.size() == 1 && views.stderr.added[0].line == "hello from Perl" && views.stderr.added[0].ending == "\n")assert subject != "target" || (views.stderr.added.size() == 1 && views.stderr.added[0].line == "hello from Rust" && views.stderr.added[0].ending == "\n")assert views.device_skip.added.size() == 1assert views.device_skip.removed.size() == 0assert views.device_skip.added.all(entry, entry.row.contains("\"backend\":\"parity\""))assert views.device_skip.added.all(entry, entry.row.contains("\"device\":\"192.0.2.10\""))assert views.device_skip.added.all(entry, entry.row.contains("\"deferrals\":0"))  }
differs at views.stderr.added.*.linewhere legacy == "hello from Perl" && target == "hello from Rust"because "the diagnostic identifies the implementation that answered"differs at views.stdout.added.*.linewhere legacy.contains('"log":"hello from Perl"') && target.contains('"log":"hello from Rust"')because "the reported log carries that same implementation identity; the device and status fields of the same line stay compared exactly"differs at views.admin_queue.added.*.rowwhere legacy.contains('"log":"hello from Perl"') && target.contains('"log":"hello from Rust"')because "completing job 9001 stores the answering worker's log in the admin row, so only that column of the row carries the identity; status, timestamps and every other column stay compared"differs at views.tasty_completion.added.*.rowwhere legacy.contains('"log":"hello from Perl"') && target.contains('"log":"hello from Rust"')because "the completion view repeats job 9001 from admin_queue; legacy stores hello from Perl and the native diagnostic stores hello from Rust, while every other column stays identical"differs at views.postgres_state.added.*.after.rows.*.logwhere legacy == "hello from Perl" && target == "hello from Rust"because "the automatic state view carries the same admin row, so the same stored worker log reaches it; the predicate waives only that one implementation identity and every other column of every observed relation stays compared"}

Request another user's key

getapikey2 casesrequire targetforbiddiffersexclude @getapikey_observation@getapikey_native openspec/changes/migrate-getapikey/specs/getapikey-worker-action/spec.md

proof getapikey.exact-user {
  @getapikey_observation
  @getapikey_native
example operator { case = { name = "alice", state = "valid", mode = "normal", request = "selected" } }example cli-selected { case = { name = "bob", state = "null", mode = "cli", request = "selected" } }given prepared = getapikey(phase = "prepare", case)when getapikey(phase = "run", case)  then {
    assert result.exit_code == 0
assert views.stdout.added.exists(v, v.line.contains("\"status\":\"done\""))  }
}

Supply the false string

getapikey1 caserequire targetforbiddiffersexclude @getapikey_observation@getapikey_native openspec/changes/migrate-getapikey/specs/getapikey-worker-action/spec.md

proof getapikey.false-string {
  @getapikey_observation
  @getapikey_native
example zero { case = { name = "alice", state = "valid", mode = "normal", request = "0" } }given prepared = getapikey(phase = "prepare", case)when getapikey(phase = "run", case)  then {
    assert result.exit_code == 0
assert views.stdout.added.exists(v, v.line.contains("Missing user (-e)."))  }
}

User lookup is denied

getapikey1 caserequire targetforbiddiffersexclude @getapikey_observation@getapikey_native openspec/changes/migrate-getapikey/specs/getapikey-worker-action/spec.md

proof getapikey.lookup-denied {
  @getapikey_observation
  @getapikey_native
example denied { case = { name = "bob", state = "valid", mode = "deny-select", request = "selected" } }given prepared = getapikey(phase = "prepare", case)when getapikey(phase = "run", case)  then {
    assert result.exit_code == 0
assert views.stdout.added.exists(v, v.line.contains("permission denied for table users") && v.line.contains("\"status\":\"error\""))  }
}

Omit the user

getapikey2 casesrequire targetforbiddiffersexclude @getapikey_observation@getapikey_native openspec/changes/migrate-getapikey/specs/getapikey-worker-action/spec.md

proof getapikey.missing {
  @getapikey_observation
  @getapikey_native
example omitted { case = { name = "alice", state = "valid", mode = "normal", request = "missing" } }example empty { case = { name = "bob", state = "valid", mode = "normal", request = "" } }given prepared = getapikey(phase = "prepare", case)when getapikey(phase = "run", case)  then {
    assert result.exit_code == 0
assert views.stdout.added.exists(v, v.line.contains("Missing user (-e).") && v.line.contains("\"status\":\"error\""))  }
}

Purpose

getapikey1 casediffersexclude @getapikey_observation openspec/changes/migrate-getapikey/specs/getapikey-worker-action/spec.md

proof getapikey.namespace-retained {
  @getapikey_observation
  exclude views.postgres
where view.request.sql.startsWith("UPDATE admin SET action =")because "namespace close writes localtime parameters in SQL not projectable by Parity; every Admin column and exact claimed-job completion remain checked"example namespace { case = { name = "dave", state = "valid", mode = "namespace", request = "selected" } }given prepared = getapikey(phase = "prepare", case)when getapikey(phase = "run", case)  then {
    assert result.exit_code == 0
assert views.stdout.added.exists(v, v.line.contains("failed to report from any worker!"))  }
}

Remove the action worker

getapikey1 caserequire targetforbiddiffersexclude @getapikey_observation@getapikey_native openspec/changes/migrate-getapikey/specs/getapikey-worker-action/spec.md

proof getapikey.omitted-worker {
  @getapikey_observation
  @getapikey_native
example omitted { case = { name = "alice", state = "valid", mode = "omitted", request = "selected" } }given prepared = getapikey(phase = "prepare", case)when getapikey(phase = "run", case)  then {
    assert result.exit_code == 0
assert views.stdout.added.exists(v, v.line.contains("failed to report from any worker!"))  }
}

Supply an optional device and port

getapikey1 caserequire targetforbiddiffersexclude @getapikey_observation@getapikey_native openspec/changes/migrate-getapikey/specs/getapikey-worker-action/spec.md

proof getapikey.optional-device {
  exclude views.postgres
where view.request.sql.contains("age(") && view.request.sql.contains("device")because "production Device with_times reads clock-relative ages; device state and canonical device_skip completion remain fully compared"  @getapikey_observation
  @getapikey_native
example alias-port { case = { name = "alice", state = "valid", mode = "device", request = "selected" } }given prepared = getapikey(phase = "prepare", case)when getapikey(phase = "run", case)  then {
    assert result.exit_code == 0
assert views.stdout.added.exists(v, v.line.contains("\"device\":\"192.0.2.10\""))assert views.device_skip.added.size() == 2assert views.device_skip.added.exists(v, v.row.contains("192.0.2.10") && v.row.contains("\"deferrals\":0"))  }
}

Preserve an exact printable username

getapikey2 casesrequire targetforbiddiffersexclude @getapikey_observation@getapikey_native openspec/changes/migrate-getapikey/specs/getapikey-worker-action/spec.md

proof getapikey.printable {
  @getapikey_observation
  @getapikey_native
example punctuation { case = { name = "a b'%_", state = "valid", mode = "normal", request = "selected" } }example unicode { case = { name = "élise", state = "valid", mode = "normal", request = "selected" } }given prepared = getapikey(phase = "prepare", case)when getapikey(phase = "run", case)then { assert result.exit_code == 0 }}

Requirement: Respect the selected workers and runtime modes

getapikey1 caserequire targetforbiddiffersexclude @getapikey_observation@getapikey_native openspec/changes/migrate-getapikey/specs/getapikey-worker-action/spec.md

proof getapikey.provider-connection-failure {  @getapikey_observation
  @getapikey_native
example authentication { case = { name = "alice", state = "valid", mode = "provider-auth", request = "selected" } }given prepared = getapikey(phase = "prepare", case)when getapikey(phase = "run", case)  then {
    assert result.exit_code == 0
assert views.stdout.added.exists(v, v.line.contains("\"status\":\"done\"") && v.line.contains("\"token_checked\":1"))  }
}

The users provider is misconfigured

getapikey1 caserequire targetforbiddiffersexclude @getapikey_observation@getapikey_native openspec/changes/migrate-getapikey/specs/getapikey-worker-action/spec.md

proof getapikey.provider-error {
  @getapikey_observation
  @getapikey_native
example missing-provider { case = { name = "carol", state = "valid", mode = "provider", request = "selected" } }given prepared = getapikey(phase = "prepare", case)when getapikey(phase = "run", case)  then {
    assert result.exit_code == 0
assert views.stdout.added.exists(v, v.line.contains("Proof::MissingProvider") && v.line.contains("\"status\":\"error\""))  }
}

Token lookup fails inside the provider

getapikey1 caserequire targetforbiddiffersexclude @getapikey_observation@getapikey_native openspec/changes/migrate-getapikey/specs/getapikey-worker-action/spec.md

proof getapikey.provider-schema {
  @getapikey_observation
  @getapikey_native
example separate-repository { case = { name = "carol", state = "valid", mode = "provider-schema", request = "selected" } }given prepared = getapikey(phase = "prepare", case)when getapikey(phase = "run", case)  then {
    assert result.exit_code == 0
assert views.stdout.added.exists(v, v.line.contains("\"status\":\"done\"") && v.line.contains("\"token_checked\":1"))  }
}

Purpose

getapikey3 casesforbiddiffersexclude @getapikey_observation openspec/changes/migrate-getapikey/specs/getapikey-worker-action/spec.md

proof getapikey.retained-boundary {
  @getapikey_observation
  forbid target views.local_ipc
where view.resource == "unix+stream:///tmp/netdisco-parity-worker.sock"because "oversize envelopes and a disabled bridge retain legacy before connecting"example oversize { case = { name = "alice", state = "valid", mode = "normal", request = "oversize" } }example disabled { case = { name = "bob", state = "expired", mode = "disabled", request = "selected" } }example unavailable { case = { name = "carol", state = "null", mode = "unavailable", request = "selected" } }given prepared = getapikey(phase = "prepare", case)when getapikey(phase = "run", case)then { assert result.exit_code == 0 }}

Roll-call does not issue a key

getapikey3 casesrequire targetforbiddiffersexclude @getapikey_observation@getapikey_native openspec/changes/migrate-getapikey/specs/getapikey-worker-action/spec.md

proof getapikey.roll-call {
  @getapikey_observation
  @getapikey_native
example selected { case = { name = "alice", state = "valid", mode = "roll-call", request = "selected" } }example missing { case = { name = "bob", state = "valid", mode = "roll-call", request = "missing" } }example unknown { case = { name = "carol", state = "valid", mode = "roll-call", request = "unknown" } }given prepared = getapikey(phase = "prepare", case)when getapikey(phase = "run", case)  then {
    assert result.exit_code == 0
assert views.stdout.added.exists(v, v.line.contains("\"log\":\"-\"") && v.line.contains("\"status\":\"info\""))  }
}

Rollback reports without saving

getapikey1 caserequire targetforbiddiffersexclude @getapikey_observation@getapikey_native openspec/changes/migrate-getapikey/specs/getapikey-worker-action/spec.md

proof getapikey.rollback {
  exclude target views.postgres
where view.request.sql == "SAVEPOINT nd_session_query" || view.request.sql == "RELEASE SAVEPOINT nd_session_query"because "native recovery savepoints are inside the same outer rollback; the mapped write and rollback plus unchanged rows prove the legacy transaction effect"  map rollback_write {
    legacy {
writes = views.postgres.added.filter(v, v.request.sql.startsWith("UPDATE users SET token = md5(random()::text)") && v.phase == "when")      assert writes.size() == 1
into writes.map(v, {"connection": v.resource, "sql": v.request.sql, "username": v.request.parameters[1], "response": v.response})    }
    target {
writes = views.postgres.added.filter(v, v.request.sql.startsWith("UPDATE users SET token = md5(random()::text)") && v.phase == "when")      assert writes.size() == 1
into writes.map(v, {"connection": v.resource, "sql": v.request.sql, "username": v.request.parameters[1], "response": v.response})    }
} because "legacy issues and rolls back a real user write; compare exact SQL, username, connection and affected rows independently of cross-connection capture order, omitting only the epoch checked by the driver"  map rollback_controls {
    legacy {
begins = views.postgres.added.filter(v, v.request.sql.matches("^(begin|BEGIN)$"))commits = views.postgres.added.filter(v, v.request.sql.matches("^(commit|COMMIT)$"))rollbacks = views.postgres.added.filter(v, v.request.sql.matches("^(rollback|ROLLBACK)$"))      assert rollbacks.size() == 1
into [{"begin": begins.size(), "commit": commits.size(), "rollback": rollbacks.size()}]    }
    target {
begins = views.postgres.added.filter(v, v.request.sql.matches("^(begin|BEGIN)$"))commits = views.postgres.added.filter(v, v.request.sql.matches("^(commit|COMMIT)$"))rollbacks = views.postgres.added.filter(v, v.request.sql.matches("^(rollback|ROLLBACK)$"))      assert rollbacks.size() == 1
into [{"begin": begins.size(), "commit": commits.size(), "rollback": rollbacks.size()}]    }
} because "legacy rolls back the user transaction and commits queue completion on separate connections; connection-group capture order and SQL keyword case are not behavior, and unchanged users plus exact completion retain both outcomes"  map rollback_warning {
    legacy {
warnings = views.stderr.added.filter(v, v.line.contains("DBIx::Class::Storage::TxnScopeGuard::DESTROY(): A DBIx::Class::Storage::TxnScopeGuard went out of scope"))assert warnings.size() == 1 && warnings.all(v, v.line.matches("^DBIx::Class::Storage::TxnScopeGuard::DESTROY\\(\\): A DBIx::Class::Storage::TxnScopeGuard went out of scope without explicit commit or error\\. Rolling back\\. at lib/App/Netdisco/Worker/Runner.pm line [0-9]+$"))into warnings.map(v, {"message": "DBIx::Class::Storage::TxnScopeGuard::DESTROY(): A DBIx::Class::Storage::TxnScopeGuard went out of scope without explicit commit or error. Rolling back.", "ending": v.ending})    }
    target {
warnings = views.stderr.added.filter(v, v.line.contains("DBIx::Class::Storage::TxnScopeGuard::DESTROY(): A DBIx::Class::Storage::TxnScopeGuard went out of scope"))assert warnings.size() == 1 && warnings.all(v, v.line.matches("^DBIx::Class::Storage::TxnScopeGuard::DESTROY\\(\\): A DBIx::Class::Storage::TxnScopeGuard went out of scope without explicit commit or error\\. Rolling back\\. at native runner line [0-9]+$"))into warnings.map(v, {"message": "DBIx::Class::Storage::TxnScopeGuard::DESTROY(): A DBIx::Class::Storage::TxnScopeGuard went out of scope without explicit commit or error. Rolling back.", "ending": v.ending})    }
} because "legacy Runner.pm emits the complete rollback warning with its source location; only the implementation source location changes"  @getapikey_observation
  @getapikey_native
example expired { case = { name = "alice", state = "expired", mode = "rollback", request = "selected" } }given prepared = getapikey(phase = "prepare", case)when getapikey(phase = "run", case)  then {
    assert result.exit_code == 0
assert views.stdout.added.exists(v, v.line.contains("\"status\":\"done\""))assert views.stderr.added.filter(v, v.line.contains("Rolling back.")).size() == 1  }
}

Deployment adds workers for this action

getapikey1 caseforbiddiffersexclude @getapikey_observation openspec/changes/migrate-getapikey/specs/getapikey-worker-action/spec.md

proof getapikey.site-retained {
  @getapikey_observation
  forbid target views.local_ipc
where view.resource == "unix+stream:///tmp/netdisco-parity-worker.sock"because "configured site workers retain the entire legacy plan before connecting"example late-worker { case = { name = "alice", state = "valid", mode = "site", request = "selected" } }given prepared = getapikey(phase = "prepare", case)when getapikey(phase = "run", case)  then {
    assert result.exit_code == 0
assert views.stderr.added.exists(v, v.line == "site getapikey ran")assert views.stdout.added.exists(v, v.line.contains("site getapikey completed"))  }
}

Token lookup fails inside the provider

getapikey1 caserequire targetforbiddiffersexclude @getapikey_observation@getapikey_native openspec/changes/migrate-getapikey/specs/getapikey-worker-action/spec.md

proof getapikey.token-lookup-error {
  @getapikey_observation
  @getapikey_native
example column { case = { name = "bob", state = "valid", mode = "token-column", request = "selected" } }given prepared = getapikey(phase = "prepare", case)when getapikey(phase = "run", case)  then {
    assert result.exit_code == 0
assert views.stdout.added.exists(v, v.line.contains("\"status\":\"done\""))  }
}

Renew a valid key

getapikey9 cases6 generatedrequire targetforbiddiffersexcludegenerate ×6 @getapikey_observation@getapikey_native openspec/changes/migrate-getapikey/specs/getapikey-worker-action/spec.md

proof getapikey.token-matrix {
  @getapikey_observation
  @getapikey_native
example valid { case = { name = "alice", state = "valid", mode = "normal", request = "selected" } }example expired { case = { name = "bob", state = "expired", mode = "normal", request = "selected" } }example cleared { case = { name = "carol", state = "null", mode = "normal", request = "selected" } }  generate 6 cases {
case = { name = text matching "user[a-z]{1,4}", state = one of ["valid", "expired", "null"], mode = "normal", request = "selected" }  }
given prepared = getapikey(phase = "prepare", case)when getapikey(phase = "run", case)  then {
    assert result.exit_code == 0
assert views.stdout.added.exists(v, v.line.contains("\"token_checked\":1") && v.line.contains("\"status\":\"done\""))  }
}

Supply an unmatched name

getapikey4 casesrequire targetforbiddiffersexclude @getapikey_observation@getapikey_native openspec/changes/migrate-getapikey/specs/getapikey-worker-action/spec.md

proof getapikey.unmatched {
  @getapikey_observation
  @getapikey_native
example case-sensitive { case = { name = "alice", state = "valid", mode = "normal", request = "case" } }example spaces { case = { name = "bob", state = "valid", mode = "normal", request = "spaces" } }example pattern { case = { name = "carol", state = "valid", mode = "normal", request = "pattern" } }example utf8-boundary { case = { name = "dave", state = "valid", mode = "normal", request = "boundary" } }given prepared = getapikey(phase = "prepare", case)when getapikey(phase = "run", case)  then {
    assert result.exit_code == 0
assert views.stdout.added.exists(v, v.line.contains("No such user"))  }
}

Requirement: Respect the selected workers and runtime modes

getapikey1 caserequire targetforbiddiffersexclude @getapikey_observation@getapikey_native openspec/changes/migrate-getapikey/specs/getapikey-worker-action/spec.md

proof getapikey.update-connection-failure {  @getapikey_observation
  @getapikey_native
example authentication { case = { name = "carol", state = "valid", mode = "update-auth", request = "selected" } }given prepared = getapikey(phase = "prepare", case)when getapikey(phase = "run", case)  then {
    assert result.exit_code == 0
assert views.stdout.added.exists(v, v.line.contains("DBIx::Class::Storage::DBI::catch {...} (): DBI Connection failed:") && v.line.contains("\"status\":\"error\""))assert views.admin_queue.added.exists(v, v.row.contains("\"log\":\"\""))  }
}

The selected user cannot be updated

getapikey1 caserequire targetforbiddiffersexclude @getapikey_observation@getapikey_native openspec/changes/migrate-getapikey/specs/getapikey-worker-action/spec.md

proof getapikey.update-denied {
  @getapikey_observation
  @getapikey_native
example denied { case = { name = "alice", state = "valid", mode = "deny-update", request = "selected" } }given prepared = getapikey(phase = "prepare", case)when getapikey(phase = "run", case)  then {
    assert result.exit_code == 0
assert views.stdout.added.exists(v, v.line.contains("permission denied for table users") && v.line.contains("\"status\":\"error\""))  }
}

A zero lifetime still allows issuing a key

getapikey1 caserequire targetforbiddiffersexclude @getapikey_observation@getapikey_native openspec/changes/migrate-getapikey/specs/getapikey-worker-action/spec.md

proof getapikey.zero-lifetime {
  @getapikey_observation
  @getapikey_native
example zero { case = { name = "alice", state = "valid", mode = "zero-lifetime", request = "selected" } }given prepared = getapikey(phase = "prepare", case)when getapikey(phase = "run", case)then { assert result.exit_code == 0 }}

Finish a claimed preview

tastyjobs1 caserequire targetforbiddiffersexclude @tasty_native@tasty_done openspec/changes/migrate-tastyjobs/specs/tastyjobs-worker-action/spec.md

proof tastyjobs.completion {
  @tasty_native
example claimed { fixture = { count = 3 } }  given tastyjobs_prepare(fixture)
  when tastyjobs(fixture)
  @tasty_done
  then {
assert !views.tasty_completion.added[0].row.contains('"started":null')assert !views.tasty_completion.added[0].row.contains('"finished":null')assert views.tasty_completion.added[0].row.contains('"job":9001')  }
}

Default preview

tastyjobs3 casesrequire targetforbiddiffersexclude @tasty_native@tasty_done openspec/changes/migrate-tastyjobs/specs/tastyjobs-worker-action/spec.md

proof tastyjobs.default {
  @tasty_native
example absent { fixture = { count = 23 } }example empty { fixture = { count = 23, limit = "" } }example zero { fixture = { count = 23, limit = "0" } }  given tastyjobs_prepare(fixture)
  when tastyjobs(fixture)
  @tasty_done
then { assert views.stderr.added.filter(line, line.line.contains("job_priority")).size() == 20 }}

Main worker disabled

tastyjobs1 caserequire targetforbiddiffersexclude @tasty_native openspec/changes/migrate-tastyjobs/specs/tastyjobs-worker-action/spec.md

proof tastyjobs.disabled {
  @tasty_native
example no_main { fixture = { count = 3, disabled = true } }  given tastyjobs_prepare(fixture)
  when tastyjobs(fixture)
  then {
assert views.stdout.added.exists(line, line.line == '{"log":"failed to report from any worker!","status":"error"}')assert views.stderr.added.size() == 0assert views.tasty_completion.added.size() == 1 && views.tasty_completion.removed.size() == 1assert views.stdout.added.exists(line, line.line == '{"preview_unchanged":true}')  }
}

Different effective backend settings

tastyjobs2 casesrequire targetforbiddiffersexclude @tasty_native@tasty_done openspec/changes/migrate-tastyjobs/specs/tastyjobs-worker-action/spec.md

proof tastyjobs.effective-settings {
  @tasty_native
example restrictive { fixture = { backend = "tenant-a", max_deferrals = 1, retry_after = "100 years", high = ["snapshot"], rows = [{job = 1, action = "snapshot", device = "192.0.2.10"}], skips = [{device = "192.0.2.10", deferrals = 2, last_defer = "2026-01-01 00:00:00"}] }, expected = 0 }example permissive { fixture = { backend = "tenant-b", max_deferrals = 3, retry_after = "1 second", high = ["snapshot"], rows = [{job = 1, action = "snapshot", device = "192.0.2.10"}], skips = [{backend = "tenant-a", device = "192.0.2.10", actionset = ["snapshot"], deferrals = 2, last_defer = "2026-01-01 00:00:00"}] }, expected = 1 }  given tastyjobs_prepare(fixture)
  when tastyjobs(fixture)
  @tasty_done
then { assert views.stderr.added.filter(line, line.line.contains("job_priority")).size() == examples.expected }}

Mixed queue eligibility

tastyjobs1 caserequire targetforbiddiffersexclude @tasty_native@tasty_done openspec/changes/migrate-tastyjobs/specs/tastyjobs-worker-action/spec.md

proof tastyjobs.eligibility {
  @tasty_native
  example mixed { fixture = {
    high = ["high"],
    rows = [
{job = 1, action = "snapshot", device_key = "a"},{job = 2, action = "snapshot", device_key = "b", username = "operator"},{job = 3, action = "high", device_key = "c"},{job = 4, action = "snapshot", device_key = "d", backend = "claimed"},{job = 5, action = "snapshot", device_key = "e", status = "done"},{job = 6, action = "snapshot", device = "192.0.2.10", device_key = "f"},{job = 7, action = "snapshot", device = "192.0.2.20", device_key = "g"},{job = 8, action = "snapshot", device = "192.0.2.30", device_key = "h"}    ],
    skips = [
{device = "192.0.2.10", actionset = ["snapshot"]},{device = "192.0.2.20", deferrals = 10, last_defer = "2099-01-01 00:00:00"},{device = "192.0.2.30", deferrals = 1, last_defer = "2026-01-01 00:00:00"}    ]
  } }
  given tastyjobs_prepare(fixture)
  when tastyjobs(fixture)
  @tasty_done
then { assert views.stderr.added.filter(line, line.line.contains("job_priority")).size() == 4 }}

Late permission helper fails

tastyjobs1 caserequire targetforbiddiffersexclude @tasty_native openspec/changes/migrate-tastyjobs/specs/tastyjobs-worker-action/spec.md

proof tastyjobs.helper-error {
  @tasty_native
example invalid_acl { fixture = { discover_no = ["vendor:["], devices = [{ip = "192.0.2.10", vendor = "test"}], rows = [{job = 1, action = "snapshot", device_key = "z"}, {job = 2, action = "snapshot", device = "192.0.2.10", device_key = "a"}, {job = 0, action = "snapshot", backend = "active", started = "2000-01-01 00:00:00"}] } }  given tastyjobs_prepare(fixture)
  when tastyjobs(fixture)
  then {
assert views.stdout.added.exists(line, line.line.contains('"status":"error"') && line.line.contains("Unmatched"))assert views.tasty_completion.added.size() == 1 && views.tasty_completion.removed.size() == 1assert views.stderr.added.filter(line, line.line.contains("job_priority")).size() == 0assert views.stdout.added.exists(line, line.line == '{"preview_unchanged":true}')  }
}

Signed and whitespace integer text

tastyjobs2 casesrequire targetforbiddiffersexclude @tasty_native@tasty_done openspec/changes/migrate-tastyjobs/specs/tastyjobs-worker-action/spec.md

proof tastyjobs.integer-text {
  @tasty_native
example signed { fixture = { count = 23, limit = "+2" }, expected = 2 }example padded { fixture = { count = 23, limit = " 7 " }, expected = 7 }  given tastyjobs_prepare(fixture)
  when tastyjobs(fixture)
  @tasty_done
then { assert views.stderr.added.filter(line, line.line.contains("job_priority")).size() == examples.expected }}

Newly denied candidate

tastyjobs6 casesrequire targetforbiddiffersexclude @tasty_native@tasty_done openspec/changes/migrate-tastyjobs/specs/tastyjobs-worker-action/spec.md

proof tastyjobs.late-denial {
  @tasty_native
example acl_alias { fixture = { limit = "1", high = ["discover"], discover_no = ["192.0.2.10"], devices = [{ip = "192.0.2.10", layers = "00000110"}], aliases = [{ip = "192.0.2.10", alias = "192.0.2.11"}], rows = [{job = 1, action = "discover", device = "192.0.2.11", device_key = "z"}, {job = 2, action = "snapshot", device_key = "a"}], skips = [{device = "192.0.2.11", actionset = ["existing", "existing"]}] }, expected_skip = "192.0.2.11" }example layer { fixture = { limit = "1", devices = [{ip = "192.0.2.10", layers = "00000000"}], rows = [{job = 1, action = "arpnip", device = "192.0.2.10", device_key = "z"}, {job = 2, action = "snapshot", device_key = "a"}] } }example pseudo { fixture = { limit = "1", devices = [{ip = "192.0.2.10", is_pseudo = true}], rows = [{job = 1, action = "discover", device = "192.0.2.10", device_key = "z"}, {job = 2, action = "snapshot", device_key = "a"}] } }example schedule { fixture = { limit = "1", schedule = { nightly = { action = "snapshot", no = ["192.0.2.10"] } }, rows = [{job = 1, action = "scheduled-nightly", device = "192.0.2.10", device_key = "z"}, {job = 2, action = "snapshot", device_key = "a"}] } }example property { fixture = { limit = "1", discover_no = ["vendor:test"], devices = [{ip = "192.0.2.10", vendor = "test"}], rows = [{job = 1, action = "discover", device = "192.0.2.10", device_key = "z"}, {job = 2, action = "snapshot", device_key = "a"}] } }example only_miss { fixture = { limit = "1", discover_only = ["vendor:other"], devices = [{ip = "192.0.2.10", vendor = "test"}], rows = [{job = 1, action = "discover", device = "192.0.2.10", device_key = "z"}, {job = 2, action = "snapshot", device_key = "a"}] } }  given tastyjobs_prepare(fixture)
  when tastyjobs(fixture)
  @tasty_done
then { assert views.stderr.added.exists(line, line.line == "[]") }  then {
assert !has(examples.expected_skip) || mapped.preview_effects[0].writes == ['parity_tastyjobs_write {"rows": [{"device": "192.0.2.11", "backend": "parity-tasty", "actionset": ["discover", "existing"], "deferrals": 0, "last_defer": null}], "table": "device_skip", "operation": "UPDATE"}']assert !has(examples.expected_skip) || mapped.preview_effects[0].rollbacks == ["ROLLBACK"]  }
}

Two requested limits

tastyjobs2 casesrequire targetforbiddiffersexclude @tasty_native@tasty_done openspec/changes/migrate-tastyjobs/specs/tastyjobs-worker-action/spec.md

proof tastyjobs.limits {
  @tasty_native
example two { fixture = { count = 23, limit = "2" }, expected = 2 }example seven { fixture = { count = 23, limit = "7" }, expected = 7 }  given tastyjobs_prepare(fixture)
  when tastyjobs(fixture)
  @tasty_done
then { assert views.stderr.added.filter(line, line.line.contains("job_priority")).size() == examples.expected }}

Full long exception log

tastyjobs1 caserequire targetforbiddiffersexclude @tasty_native openspec/changes/migrate-tastyjobs/specs/tastyjobs-worker-action/spec.md

proof tastyjobs.long-error {
  @tasty_native
example bounded { fixture = { count = 1, long_limit = true } }  given tastyjobs_prepare(fixture)
  when tastyjobs(fixture)
  then {
assert views.stdout.added.exists(line, line.line.contains('"status":"error"') && line.line.size() > 8000)assert views.tasty_completion.added.size() == 1 && views.tasty_completion.removed.size() == 1assert views.tasty_completion.added[0].row.size() > 8000// The input remains persisted, but the exception object itself is not.assert views.tasty_completion.added[0].row.contains('"log":""')assert views.stderr.added.filter(line, line.line.contains("_statuslist")).size() == 0assert views.stdout.added.exists(line, line.line == '{"preview_unchanged":true}')  }
}

Newer active or stale equivalent

tastyjobs2 casesrequire targetforbiddiffersexclude @tasty_native@tasty_done openspec/changes/migrate-tastyjobs/specs/tastyjobs-worker-action/spec.md

proof tastyjobs.newer-duplicates {
  @tasty_native
example recent { fixture = { rows = [{job = 1, action = "snapshot", device_key = "same", log = "fetched original"}, {job = 2, action = "snapshot", device_key = "same", backend = "active", started = "2099-01-01 00:00:00"}] } }example stale { fixture = { rows = [{job = 1, action = "snapshot", device_key = "same", log = "fetched original"}, {job = 2, action = "snapshot", device_key = "same", backend = "active", started = "2000-01-01 00:00:00"}] } }  given tastyjobs_prepare(fixture)
  when tastyjobs(fixture)
  @tasty_done
then { assert views.stderr.added.exists(line, line.line.contains("fetched original")) }}

Nonpositive and nonnumeric text

tastyjobs4 casesrequire targetforbiddiffersexclude @tasty_native@tasty_done openspec/changes/migrate-tastyjobs/specs/tastyjobs-worker-action/spec.md

proof tastyjobs.nonpositive {
  @tasty_native
example negative { fixture = { count = 3, limit = "-1" } }example double_zero { fixture = { count = 3, limit = "00" } }example decimal_zero { fixture = { count = 3, limit = "0.0" } }example text { fixture = { count = 3, limit = "text" } }  given tastyjobs_prepare(fixture)
  when tastyjobs(fixture)
  @tasty_done
then { assert views.stderr.added.exists(line, line.line == "[]") }}

Older automatic duplicates

tastyjobs3 casesrequire targetforbiddiffersexclude @tasty_native@tasty_done openspec/changes/migrate-tastyjobs/specs/tastyjobs-worker-action/spec.md

proof tastyjobs.older-duplicates {
  @tasty_native
// Configured high priority prevents the user row from consuming the limit.// The different device_keys still match through the NULL device branch.example field_exclusions { fixture = { limit = "1", high = ["snapshot"], rows = [{job = 10, action = "snapshot", device_key = "z", port = "p", subaction = "x"},{job = 1, action = "snapshot", device_key = "a", port = "p", subaction = "x"},{job = 2, action = "snapshot", device_key = "b", port = "p", subaction = "x", username = "operator"},{job = 3, action = "snapshot", device_key = "c", port = "p", subaction = "x", userip = "192.0.2.1"},{job = 4, action = "snapshot", device_key = "d", port = "other", subaction = "x"},{job = 5, action = "snapshot", device_key = "e", port = "p", subaction = "other"},{job = 6, action = "other", device_key = "f", port = "p", subaction = "x"}  ] }, expected_duplicate = true }
example exact { fixture = { limit = "1", rows = [{job = 10, action = "snapshot", device_key = "z", port = "p", subaction = "x"},{job = 1, action = "snapshot", device_key = "a", port = "p", subaction = "x"},{job = 2, action = "snapshot", device_key = "b", port = "p", subaction = "x", username = "operator"},{job = 3, action = "snapshot", device_key = "c", port = "p", subaction = "x", userip = "192.0.2.1"},{job = 4, action = "snapshot", device_key = "d", port = "other", subaction = "x"},{job = 5, action = "snapshot", device_key = "e", port = "p", subaction = "other"},{job = 6, action = "other", device_key = "f", port = "p", subaction = "x"}  ] } }
example nulls { fixture = { limit = "1", rows = [{job = 10, action = "snapshot", device_key = "z"}, {job = 1, action = "snapshot", device_key = "a"}] } }  given tastyjobs_prepare(fixture)
  when tastyjobs(fixture)
  @tasty_done
then { assert views.stderr.added.filter(line, line.line.contains("job_priority")).size() == 1 }  then {
assert !has(examples.expected_duplicate) || mapped.preview_effects[0].writes == ['parity_tastyjobs_write {"rows": [{"job": 1, "log": "duplicate of 10", "status": "info"}], "table": "admin", "operation": "UPDATE"}']assert !has(examples.expected_duplicate) || mapped.preview_effects[0].rollbacks == ["ROLLBACK"]  }
}

Retry after an earlier deferral

tastyjobs1 caserequire targetforbiddiffersexclude @tasty_native@tasty_done openspec/changes/migrate-tastyjobs/specs/tastyjobs-worker-action/spec.md

proof tastyjobs.ordering {
  @tasty_native
example cutting { fixture = { limit = "3", rows = [{job = 1, action = "snapshot", device_key = "a"},{job = 2, action = "snapshot", device = "192.0.2.20", device_key = "b"},{job = 3, action = "snapshot", device = "192.0.2.30", device_key = "c"},{job = 4, action = "snapshot", device = "192.0.2.40", device_key = "z"},{job = 5, action = "snapshot", device = "192.0.2.50", device_key = "y"}  ], skips = [
{device = "192.0.2.20", deferrals = 0, last_defer = "2000-01-01 00:00:00"},{device = "192.0.2.30", deferrals = 0, last_defer = "2001-01-01 00:00:00"},{device = "192.0.2.40", deferrals = 0},{device = "192.0.2.50", deferrals = 1, last_defer = "1999-01-01 00:00:00"}  ] } }
  given tastyjobs_prepare(fixture)
  when tastyjobs(fixture)
  @tasty_done
then { assert views.stderr.added.filter(line, line.line.contains("job_priority")).size() == 3 }}

Denied actions do not include this candidate

tastyjobs5 casesrequire targetforbiddiffersexclude @tasty_native@tasty_done openspec/changes/migrate-tastyjobs/specs/tastyjobs-worker-action/spec.md

proof tastyjobs.other-denials {
  @tasty_native
example allowed { fixture = { discover_no = ["192.0.2.10"], rows = [{job = 1, action = "snapshot", device = "192.0.2.10"}], skips = [{device = "192.0.2.10", actionset = ["existing"]}] } }example pseudo_cached { fixture = { devices = [{ip = "192.0.2.10", is_pseudo = true}], cached = [{ip = "192.0.2.10", oid = "1.3.6.1", oid_parts = "{1,3,6,1}", value = ["cached"]}], rows = [{job = 1, action = "discover", device = "192.0.2.10"}] } }example layer_override { fixture = { ignore_layers = ["192.0.2.10"], devices = [{ip = "192.0.2.10", layers = "00000000"}], rows = [{job = 1, action = "arpnip", device = "192.0.2.10"}] } }example force_layer { fixture = { force_arpnip = ["192.0.2.10"], devices = [{ip = "192.0.2.10", layers = "00000000"}], rows = [{job = 1, action = "arpnip", device = "192.0.2.10"}] } }example property_only { fixture = { discover_only = ["vendor:test"], devices = [{ip = "192.0.2.10", vendor = "test"}], rows = [{job = 1, action = "discover", device = "192.0.2.10"}] } }  given tastyjobs_prepare(fixture)
  when tastyjobs(fixture)
  @tasty_done
then { assert views.stderr.added.filter(line, line.line.contains("job_priority")).size() == 1 }}

Prefix and deviceless candidates

tastyjobs1 caserequire targetforbiddiffersexclude @tasty_native@tasty_done openspec/changes/migrate-tastyjobs/specs/tastyjobs-worker-action/spec.md

proof tastyjobs.permission-bypass {
  @tasty_native
example bypass { fixture = { discover_no = ["ip:["], rows = [{job = 1, action = "snapshot", device_key = "z"}, {job = 2, action = "pingsweep", device = "192.0.2.10", device_key = "a"}] } }  given tastyjobs_prepare(fixture)
  when tastyjobs(fixture)
  @tasty_done
then { assert views.stderr.added.filter(line, line.line.contains("job_priority")).size() == 2 }}

Positive text rejected by the database

tastyjobs4 casesrequire targetforbiddiffersexclude @tasty_native openspec/changes/migrate-tastyjobs/specs/tastyjobs-worker-action/spec.md

proof tastyjobs.query-errors {
  @tasty_native
example decimal { fixture = { count = 3, limit = "1.5" } }example exponent { fixture = { count = 3, limit = "1e2" } }example tail { fixture = { count = 3, limit = "2tail" } }example overflow { fixture = { count = 3, limit = "9223372036854775808" } }  given tastyjobs_prepare(fixture)
  when tastyjobs(fixture)
  then {
    assert result.exit_code == 0
assert views.stdout.added.exists(line, line.line.contains('"status":"error"') && line.line.contains("DBI Exception"))assert views.stderr.added.filter(line, line.line.contains("_statuslist")).size() == 0assert views.tasty_completion.added.size() == 1 && views.tasty_completion.removed.size() == 1assert views.tasty_completion.added[0].row.contains('"status":"error"')// Spec defect: DBIC exceptions are objects; jq_complete stores an empty// log for references. Compare the full runner text AND actual persistence.assert views.tasty_completion.added[0].row.contains('"log":""')assert views.stdout.added.exists(line, line.line == '{"preview_unchanged":true}')  }
}

Empty queue and large render

tastyjobs2 casesrequire targetforbiddiffersexclude @tasty_native@tasty_done openspec/changes/migrate-tastyjobs/specs/tastyjobs-worker-action/spec.md

proof tastyjobs.render-size {
  @tasty_native
example empty { fixture = {} , expected = 0 }example large { fixture = { count = 90, limit = "90", large = true }, expected = 90 }  given tastyjobs_prepare(fixture)
  when tastyjobs(fixture)
  @tasty_done
then { assert views.stderr.added.filter(line, line.line.contains("job_priority")).size() == examples.expected }}

Copied job values

tastyjobs1 caserequire targetforbiddiffersexclude @tasty_native@tasty_done openspec/changes/migrate-tastyjobs/specs/tastyjobs-worker-action/spec.md

proof tastyjobs.render-values {
  @tasty_native
example values { fixture = { rows = [{job = 1, action = "snapshot::properties", device_key = "z", port = "Gi1/0/é", username = "operator", userip = "192.0.2.1", log = "Unicode café — original", started = "2026-01-01 01:02:03", finished = "2026-01-01 02:03:04"}] } }  given tastyjobs_prepare(fixture)
  when tastyjobs(fixture)
  @tasty_done
  then {
assert views.stderr.added.exists(line, line.line.contains("only_namespace") && line.line.contains("properties"))assert views.stderr.added.exists(line, line.line.contains("_statuslist") && line.line.contains("[]"))  }
}

Retry after an earlier deferral

tastyjobs2 casesrequire targetforbiddiffersexclude @tasty_native@tasty_done openspec/changes/migrate-tastyjobs/specs/tastyjobs-worker-action/spec.md

proof tastyjobs.retry {
  @tasty_native
// Eligibility and priority use different null/zero predicates. In particular,// a user request with no last_defer is eligible even at max deferrals.  example nullable { fixture = {
    rows = [
{job = 1, action = "snapshot", device = "192.0.2.10", device_key = "a", username = "operator"},{job = 2, action = "snapshot", device = "192.0.2.20", device_key = "b", username = "operator"},{job = 3, action = "snapshot", device = "192.0.2.30", device_key = "c", username = "operator"},{job = 4, action = "snapshot", device = "192.0.2.40", device_key = "d"}    ],
    skips = [
{device = "192.0.2.10", deferrals = 10},{device = "192.0.2.20", deferrals = 0},{device = "192.0.2.30", deferrals = 10, last_defer = "2026-01-01 00:00:00"},{device = "192.0.2.40", deferrals = 10, last_defer = "2099-01-01 00:00:00"}    ]
  }, expected = 3 }
  example exceptions { fixture = {
    rows = [
{job = 1, action = "snapshot", device = "192.0.2.10", device_key = "a", username = "operator", entered = "2100-01-01 00:00:00"},{job = 2, action = "snapshot", device = "192.0.2.20", device_key = "b", username = "operator"},{job = 3, action = "snapshot", device = "192.0.2.30", device_key = "c"}    ],
    skips = [
{device = "192.0.2.10", deferrals = 10, last_defer = "2099-01-01 00:00:00"},{device = "192.0.2.20", deferrals = 10, last_defer = "2000-01-01 00:00:00"},{device = "192.0.2.30", deferrals = 10, last_defer = "2000-01-01 00:00:00"}    ]
  }, expected = 3 }
  given tastyjobs_prepare(fixture)
  when tastyjobs(fixture)
  @tasty_done
then { assert views.stderr.added.filter(line, line.line.contains("job_priority")).size() == examples.expected }}

Roll-call does not preview

tastyjobs1 caserequire targetforbiddiffersexclude @tasty_native openspec/changes/migrate-tastyjobs/specs/tastyjobs-worker-action/spec.md

proof tastyjobs.roll-call {
  @tasty_native
example enabled { fixture = { count = 3, roll_call = true } }  given tastyjobs_prepare(fixture)
  when tastyjobs(fixture)
  then {
assert views.stdout.added.exists(line, line.line == '{"log":"-","status":"info"}')assert views.stderr.added.size() == 0assert views.tasty_completion.added.size() == 1 && views.tasty_completion.removed.size() == 1assert views.stdout.added.exists(line, line.line == '{"preview_unchanged":true}')  }
}

Default claimed-queue preview

tastyjobs1 caserequire targetforbiddiffersexclude @tasty_native@tasty_done openspec/changes/migrate-tastyjobs/specs/tastyjobs-worker-action/spec.md

proof tastyjobs.rollback {
  @tasty_native
example both_helpers { fixture = { rows = [{job = 10, action = "snapshot", device = "192.0.2.10", device_key = "z"}, {job = 1, action = "snapshot", device = "192.0.2.10", device_key = "a"}], discover_no = ["192.0.2.10"] } }  given tastyjobs_prepare(fixture)
  when tastyjobs(fixture)
  @tasty_done
}

Site-added worker

tastyjobs1 caseforbid openspec/changes/migrate-tastyjobs/specs/tastyjobs-worker-action/spec.md

proof tastyjobs.site-fallback {
  forbid target views.local_ipc
where view.resource == "unix+stream:///tmp/netdisco-parity-worker.sock"because "site-added tastyjobs plans are intentionally unsupported and must remain legacy"example plugin { fixture = { count = 3, site = true } }  given tastyjobs_prepare(fixture)
  when tastyjobs(fixture)
  then {
assert views.stdout.added.exists(line, line.line == '{"log":"site tastyjobs completion","status":"done"}')assert views.stderr.added.exists(line, line.line == "site tastyjobs worker ran")assert views.stderr.added.filter(line, line.line.contains("job_priority")).size() == 3assert views.tasty_completion.added.size() == 1 && views.tasty_completion.removed.size() == 1assert views.stdout.added.exists(line, line.line == '{"preview_unchanged":true}')  }
}

Use no port filter

expirenodes3 casesrequire targetforbiddiffersexclude @expiry_native@expiry_done openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.all-ports {
  @expiry_native
  example absent { fixture = {} }
example empty { fixture = { port = "" } }example zero { fixture = { port = "0" } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  @expiry_done
then { assert views.stdout.added.exists(line, line.line.contains('"Node":[7,1],"NodeIp":[5,1]')) }}

Leave subaction empty or zero

expirenodes3 casesrequire targetforbiddiffersexclude @expiry_native@expiry_done openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.archive {
  @expiry_native
example absent { fixture = { port = "edge" } }example empty { fixture = { port = "edge", extra = "" } }example zero { fixture = { port = "edge", extra = "0" } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  @expiry_done
then { assert views.stdout.added.exists(line, line.line == '{"summary":{"Node":[7,2],"NodeIp":[5,2],"NodeMonitor":[4,2],"NodeNbt":[4,2]},"wireless":2}') }}

Preserve IPs for a different remaining port

expirenodes1 caserequire targetforbiddiffersexclude @expiry_native@expiry_done openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.different-port {
  @expiry_native
example inactive-remaining { fixture = { port = "edge", extra = "remove" } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  @expiry_done
then { assert views.stdout.added.exists(line, line.line.contains('"NodeIp":[3,3]')) }}

Disable ExpireNodes

expirenodes1 caserequire targetforbiddiffersexclude @expiry_native@expiry_unchanged openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.disabled {
  @expiry_native
example omitted { fixture = { mode = "disabled", auth = [{community = "unused"}] } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  @expiry_unchanged
then { assert views.stdout.added.exists(line, line.line.contains('failed to report from any worker!') && line.line.contains('"status":"error"')) }}

Reach or exceed the subaction bound

expirenodes1 caserequire targetforbiddiffersexclude @expiry_native@expiry_done openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.extra-boundary {
  @expiry_native
example utf8 { fixture = { port = "edge", extra_unit = "é", extra_repeat = 4096 } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  @expiry_done
then { assert views.stdout.added.exists(line, line.line.contains('"Node":[4,3]')) }}

Reach or exceed the subaction bound

expirenodes1 caseforbidexclude @expiry_retained@expiry_done openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.extra-overbound {
  @expiry_retained
example utf8 { fixture = { port = "edge", extra_unit = "é", extra_repeat = 4097 } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  @expiry_done
then { assert views.stdout.added.exists(line, line.line.contains('"Node":[4,3]')) }}

Exceed another bridge field limit

expirenodes1 caseforbidexclude @expiry_retained@expiry_done openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.frame-overbound {
  @expiry_retained
example metadata { fixture = { metadata_unit = "x", metadata_repeat = 65536, extra = "remove", port = "edge" } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  @expiry_done
then { assert views.stdout.added.exists(line, line.line.contains('"Node":[4,3]')) }}

Select a MAC with only inactive sightings

expirenodes2 casesrequire targetforbiddiffersexclude @expiry_native@expiry_done openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.inactive-selected {  @expiry_native
example archive { fixture = { port = "edge", extra = "", inactive_selected = true } }example remove { fixture = { port = "edge", extra = "remove", inactive_selected = true } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  @expiry_done
  then {
assert views.stdout.added.exists(line, line.line == (examples.fixture.extra == "remove"? '{"summary":{"Node":[4,3],"NodeIp":[3,3],"NodeMonitor":[2,2],"NodeNbt":[2,2]},"wireless":2}': '{"summary":{"Node":[7,2],"NodeIp":[5,2],"NodeMonitor":[4,2],"NodeNbt":[4,2]},"wireless":2}'))  }
}

Use a literal port

expirenodes4 casesrequire targetforbiddiffersexclude @expiry_native@expiry_done openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.literal-port {
  @expiry_native
example edge { fixture = { port = "edge" } }example quoted { fixture = { port = "uplink ' 雪 %_", literal = true } }example double-zero { fixture = { port = "00", literal = true } }example boundary { fixture = { port_unit = "é", port_repeat = 128, literal = true } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  @expiry_done
then { assert views.stdout.added.exists(line, line.line.contains('"Node":[7,2],"NodeIp":[5,2]')) }}

Device lookup fails before worker execution

expirenodes1 caseexclude @expiry_observation@expiry_unchanged openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.lookup-error {
  @expiry_observation
  exclude views.postgres
where view.resource == "parity://connection/expiry_no_select" && view.request.sql.startsWith("UPDATE admin SET finished")because "this failed-lookup completion binds localtime strings not decoded as clocks on the restricted session; exact exception/log identity, started/finished, status and all Admin columns are checked by the driver and persisted state remains compared"example rejected { fixture = { mode = "deny-lookup" } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  @expiry_unchanged
  then {
assert views.stdout.added.exists(line, line.line.contains('"status":"error"') && line.line.contains('error running job:') && line.line.contains('permission denied for table device_ip'))assert views.stdout.added.exists(line, line.line == '{"legacy_workers":false}')  }
}

Queue no device

expirenodes1 caserequire targetforbiddiffersexclude @expiry_native@expiry_unchanged openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.missing {
  @expiry_native
example absent { fixture = { device = null } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  @expiry_unchanged
then { assert views.stdout.added.exists(line, line.line.contains('"status":"error"') && line.line.contains('Missing device (-d).')) }}

Queue a namespaced action

expirenodes1 caseforbidexclude @expiry_retained@expiry_unchanged openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.namespace {
  @expiry_retained
example site { fixture = { mode = "namespace" } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  @expiry_unchanged
then { assert views.stdout.added.exists(line, line.line.contains('failed to report from any worker!') && line.line.contains('"status":"error"')) }}

Expire without device credentials

expirenodes2 casesrequire targetforbiddiffersexclude @expiry_native@expiry_done openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.no-credentials {
  @expiry_native
  example empty { fixture = {} }
example acl { fixture = { auth = [{community = "unused", only = ["203.0.113.0/24"]}] } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  @expiry_done
}

Select no nodes or repeat expiry

expirenodes2 casesrequire targetforbiddiffersexclude @expiry_native@expiry_done@expiry_unchanged openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.no-match {
  @expiry_native
example empty { fixture = { port = "absent" } }example remove { fixture = { port = "absent", extra = "remove" } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  @expiry_done
  @expiry_unchanged
}

Complete a normal expiry

expirenodes4 casesrequire targetforbiddiffersexclude @expiry_debug@expiry_native@expiry_done openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.output {
  @expiry_debug
  @expiry_native
example debug { fixture = { debug = true, device = "192.0.2.11", logger_format = "%L %m" } }example fractional-priority { fixture = { debug = true, direct_priority = 100.5, logger_format = "%L %m" } }example priority-spelling { fixture = { debug = true, direct_priority = "0100", logger_format = "%L %m" } }example numeric-only-integral-priority { fixture = { debug = true, numeric_only_integral_priority = true, logger_format = "%L %m" } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  @expiry_done
  then {
assert views.stderr.added.exists(line, line.line.contains('Internal::BackendFQDN'))assert views.stderr.added.exists(line, line.line.contains('Internal::SNMPFastDiscover'))assert views.stderr.added.exists(line, line.line.contains('running with configured SNMP timeouts'))assert views.stderr.added.exists(line, line.line.contains('ExpireNodes is able to run'))assert views.stderr.added.exists(line, line.line.contains('Expired nodes for 192.0.2.10'))  }
}

Exceed another bridge field limit

expirenodes1 caseforbidexclude @expiry_retained@expiry_done openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.port-overbound {
  @expiry_retained
example utf8 { fixture = { port_unit = "é", port_repeat = 129, literal = true, extra = "remove" } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  @expiry_done
then { assert views.stdout.added.exists(line, line.line.contains('"Node":[4,3]')) }}

Suppress debug output and ignore unrelated job fields

expirenodes1 caserequire targetforbiddiffersexclude @expiry_native@expiry_done openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.quiet {
  @expiry_native
example metadata { fixture = { prior_log = "previous job text" } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  @expiry_done
then { assert views.stderr.added.size() == 0 }}

Use no port filter

expirenodes3 casesrequire targetforbiddiffersexclude @expiry_native@expiry_done openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.remove-all-ports {
  @expiry_native
example absent { fixture = { extra = "remove" } }example empty { fixture = { port = "", extra = "remove" } }example zero { fixture = { port = "0", extra = "remove" } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  @expiry_done
then { assert views.stdout.added.exists(line, line.line == '{"summary":{"Node":[3,2],"NodeIp":[2,2],"NodeMonitor":[1,1],"NodeNbt":[1,1]},"wireless":1}') }}

Select no nodes or repeat expiry

expirenodes2 casesrequire targetforbiddiffersexclude @expiry_native@expiry_done@expiry_unchanged openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.repeat {
  @expiry_native
example archive { fixture = { port = "edge", repeat = true } }example remove { fixture = { port = "edge", extra = "remove", repeat = true } }given prepared = expirenodes(phase = "prepare", fixture)given first = expirenodes(phase = "run", fixture)given queued = expirenodes(phase = "requeue", fixture)when expirenodes(phase = "run", fixture)  @expiry_done
  @expiry_unchanged
}

Queue a stored switch or its interface address

expirenodes3 casesrequire targetforbiddiffersexclude @expiry_native@expiry_done openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.resolved {
  @expiry_native
example management { fixture = {} }example alias { fixture = { device = "192.0.2.11" } }example ipv6 { fixture = { device = "2001:db8::10", switch = "2001:db8::10" } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  @expiry_done
then { assert views.stdout.added.exists(line, line.line.contains('"Node":[7,1],"NodeIp":[5,1]')) }}

Request roll-call

expirenodes1 caserequire targetforbiddiffersexclude @expiry_debug@expiry_native@expiry_unchanged openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.roll-call {
  @expiry_debug
  @expiry_native
example wrappers { fixture = { mode = "roll-call", debug = true, logger_format = "%L %m" } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  @expiry_unchanged
then { assert views.stdout.added.exists(line, line.line.contains('"status":"info"') && line.line.contains('"log":"-"')) }}

Request rollback on a queued expiry

expirenodes2 casesrequire targetforbiddiffersexclude @expiry_native@expiry_done@expiry_unchanged openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.rollback {
  @expiry_native
example archive { fixture = { mode = "rollback" } }example remove { fixture = { mode = "rollback", extra = "remove" } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  @expiry_done
  @expiry_unchanged
then { assert views.stderr.added.exists(line, line.line.contains('went out of scope without explicit commit or error. Rolling back.')) }}

Remove IPs when only the same port remains elsewhere

expirenodes1 caserequire targetforbiddiffersexclude @expiry_native@expiry_done openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.same-port {
  @expiry_native
example different-switch-vlan { fixture = { port = "edge", extra = "remove" } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  @expiry_done
then { assert views.stdout.added.exists(line, line.line.contains('"Node":[4,3],"NodeIp":[3,3]')) }}

Archive a MAC seen elsewhere

expirenodes1 caserequire targetforbiddiffersexclude @expiry_native@expiry_done openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.shared-archive {
  @expiry_native
example shared { fixture = { port = "edge" } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  @expiry_done
then { assert views.stdout.added.exists(line, line.line.contains('"Node":[7,2]')) }}

Add a matching site worker or worklet

expirenodes2 casesforbidexclude @expiry_retained openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.site {
  @expiry_retained
example extra { fixture = { mode = "site" } }example stale { fixture = { mode = "stale" } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  then {
    assert result.exit_code == 0
assert views.stderr.added.filter(line, line.line == 'expiry site worker ran').size() == 1assert views.stdout.added.exists(line, line.line.contains('"log":"expiry site completed"') && line.line.contains('"status":"done"'))  }
}

Use different queue and expiry database bindings

expirenodes1 caserequire targetforbiddiffersexclude @expiry_native@expiry_done openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.split-binding {
  @expiry_native
example separate { fixture = { mode = "split", port = "edge", extra = "remove" } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  @expiry_done
then { assert views.stdout.added.exists(line, line.line.contains('"Node":[4,3],"NodeIp":[3,3]')) }}

Supply any truthy extra

expirenodes6 casesrequire targetforbiddiffersexclude @expiry_native@expiry_done openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.truthy {
  @expiry_native
example no { fixture = { port = "edge", extra = "no" } }example false { fixture = { port = "edge", extra = "false" } }example double-zero { fixture = { port = "edge", extra = "00" } }example space { fixture = { port = "edge", extra = " " } }example json { fixture = { port = "edge", extra = "{\"remove\":false}" } }example unicode { fixture = { port = "edge", extra = "雪" } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  @expiry_done
then { assert views.stdout.added.exists(line, line.line == '{"summary":{"Node":[4,3],"NodeIp":[3,3],"NodeMonitor":[2,2],"NodeNbt":[2,2]},"wireless":2}') }}

Queue an unknown but valid IP

expirenodes1 caseforbidexclude @expiry_retained@expiry_done openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.unknown {
  @expiry_retained
example orphan { fixture = { mode = "unknown", switch = "192.0.2.99", device = "192.0.2.99" } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  @expiry_done
then { assert views.stdout.added.exists(line, line.line.contains('"Node":[7,1]')) }}

The expiry database cannot perform an operation

expirenodes2 casesrequire targetforbiddiffersexclude @expiry_debug@expiry_native@expiry_unchanged openspec/changes/migrate-expirenodes/specs/expirenodes-worker-action/spec.md

proof expirenodes.write-error {
  @expiry_debug
  @expiry_native
example archive { fixture = { mode = "deny-write", debug = true, logger_format = "%L %m" } }example remove { fixture = { mode = "deny-write", extra = "remove", debug = true, logger_format = "%L %m" } }given prepared = expirenodes(phase = "prepare", fixture)when expirenodes(phase = "run", fixture)  @expiry_unchanged
  then {
assert views.stdout.added.exists(line, line.line.contains('"status":"error"') && line.line.contains('permission denied for table node_wireless') && line.line.contains('"reference_log":true') && line.line.contains('"saved_log":""'))assert views.stderr.added.exists(line, line.line.contains('permission denied for table node_wireless'))  }
}
@getapikey_native, @getapikey_observation, @expiry_observation, @expiry_debug, @expiry_native, @expiry_retained, @expiry_done, @expiry_unchanged, @tasty_native, @tasty_done
@getapikey_native = {
  require target views.local_ipc
where view.resource == "unix+stream:///tmp/netdisco-parity-worker.sock"      && view.phase == "when"
&& view.request.participants.exists(p, p.written.size > 0 && p.read.size > 0)because "native cases must send the production executor a request and receive its answer"  forbid target views.process
where view.phase == "when" && (view.resource.contains("perl") || view.resource.contains("netdisco-do"))because "the native implementation must not spawn the legacy worker to obtain its answer"// The shared driver also counts production run_workers invocations and// fails every native case if any legacy phase executes after a decline.}
@getapikey_observation = {
differs at views.postgres_state.added.*.after.rows.*.creationwhere legacy.matches("^[0-9]{4}-[0-9]{2}-[0-9]{2}T.*$") && target.matches("^[0-9]{4}-[0-9]{2}-[0-9]{2}T.*$")because "production login creates user_log timestamps independently; row identities, messages, and all other fields remain compared"differs at views.postgres_state.added.*.after.rows.*.last_onwhere legacy.matches("^[0-9]{4}-[0-9]{2}-[0-9]{2}T.*$") && target.matches("^[0-9]{4}-[0-9]{2}-[0-9]{2}T.*$")because "production authentication updates last_on independently; the driver checks every worker user column against its own pre-worker row"differs at views.postgres_state.added.*.after.rows.*.tokenwhere legacy.matches("^[0-9a-f]{32}$") && target.matches("^[0-9a-f]{32}$")because "production login and getapikey mint independent MD5 tokens; the driver checks exact token identity, preservation or replacement, and provider acceptance"differs at views.postgres_state.added.*.after.rows.*.token_from    where legacy > 0 && target > 0
because "production login records each subject's current epoch; the driver checks token age and exact untouched user columns"  exclude views.postgres
where view.request.sql.startsWith("SELECT ") && view.request.sql.contains("FROM users me")|| view.request.sql.startsWith("SELECT set_config('statement_timeout'")|| view.request.sql.startsWith("SELECT me.job, me.entered, me.started, me.finished")because "DBIC text and Rust binary user rows and session deadline setup are implementation details; the driver checks every user column, exact token/log identity, provider acceptance and completion, while persisted state and all writes remain compared"  exclude views.postgres_state
where view.resource == "parity://connection/database/database/public.admin"|| view.resource == "parity://connection/no_update/database/public.admin"|| view.resource == "parity://connection/expiry_no_select/database/public.admin"because "the normalized Admin view retains every column; the driver verifies the exact saved log against the worker and persisted key before normalizing only its random key"  exclude views.postgres
where view.request.sql.startsWith("UPDATE admin SET finished")|| view.request.sql.startsWith("SELECT me.job, me.entered, me.started, me.finished") && view.request.sql.contains("FROM admin me WHERE ( me.job =")because "these completion write/read captures embed random keys in prose; the full normalized Admin view and exact per-subject completion/key checks retain their behavior"}
@expiry_observation = {
  exclude views.postgres
where view.phase == "given" && (view.request.sql.startsWith("SELECT me.username,") || view.request.sql.startsWith("UPDATE users SET token = md5(random()::text)"))because "authenticated fixture setup mints random login tokens and epochs; production queue input, user state, all when-phase calls and effects remain compared"  exclude views.postgres
where view.phase == "when" && view.request.sql == "UPDATE admin SET finished = $1, log = $2, started = $3, status = $4 WHERE ( job = $5 )"because "opaque poller localtime binds are checked as exact saved instants/status/log by the driver; full Admin state, queue identity, claim writes and node operations remain compared"  exclude views.postgres
where view.request.sql.startsWith("SELECT me.ip, me.creation") && view.request.sql.contains("age(LOCALTIMESTAMP")because "Device.with_times derives drifting ages unused by expiry; the driver checks all stored Device/DeviceIp/DevicePort columns unchanged, canonical completion, and exact node effects; alias queries, database errors, writes and persisted state remain compared"}
@expiry_debug = {
differs at views.stderr.added.*.linewhere legacy.matches("^ (info|debug) pol \\(1\\): (starting|wrapping up) expirenodes job\\([0-9]+\\).* at [A-Z][a-z]{2} [A-Z][a-z]{2} +[0-9]+ [0-9]{2}:[0-9]{2}:[0-9]{2} [0-9]{4}$")&& target.matches("^ (info|debug) pol \\(1\\): (starting|wrapping up) expirenodes job\\([0-9]+\\).* at [A-Z][a-z]{2} [A-Z][a-z]{2} +[0-9]+ [0-9]{2}:[0-9]{2}:[0-9]{2} [0-9]{4}$")&& [" info pol (1): starting expirenodes job(10000) at "," info pol (1): wrapping up expirenodes job(10000) - status done at "," info pol (1): wrapping up expirenodes job(10000) - status error at "," info pol (1): wrapping up expirenodes job(10000) - status info at "].exists(prefix,legacy.startsWith(prefix) && target.startsWith(prefix))because "poller-owned localtime suffixes differ across processes; identical level, action, job identity, terminal status and order remain required, and persisted started/finished are checked by the driver"}
@expiry_native = {
  exclude views.postgres
where view.phase == "given" && (view.request.sql.startsWith("SELECT me.username,") || view.request.sql.startsWith("UPDATE users SET token = md5(random()::text)"))because "authenticated fixture setup mints random login tokens and epochs; production queue input, user state, all when-phase calls and effects remain compared"  exclude target views.postgres
where (view.phase == "when" || view.step == "first") && (view.request.sql == "SELECT set_config('statement_timeout', $1, false)" || view.request.sql == "SELECT host(ip) FROM device WHERE ip = $1::text::inet")because "Rust revalidates the runner-resolved stored IP and applies Runner's deadline per statement; legacy Runner resolves the same IP and enforces its deadline around the worker; canonical DeviceSkip, full device rows, errors and all node effects remain checked"  map expiry_operations {
    legacy {
calls = views.postgres.added.filter(call, (call.phase == "when" || call.step == "first") && (call.request.operation == "transaction" || call.request.sql.startsWith("UPDATE node") || call.request.sql.startsWith("DELETE FROM node")))into calls.map(call, {"resource": call.resource, "step": call.step, "tables": ["node", "node_ip", "node_nbt", "node_monitor", "node_wireless"].filter(table, call.request.sql.startsWith("UPDATE " + table + " ") || call.request.sql.startsWith("DELETE FROM " + table + " ")), "messages": call.response.messages})    }
    target {
calls = views.postgres.added.filter(call, (call.phase == "when" || call.step == "first") && (call.request.operation == "transaction" || call.request.sql.startsWith("UPDATE node") || call.request.sql.startsWith("DELETE FROM node")))into calls.map(call, {"resource": call.resource, "step": call.step, "tables": ["node", "node_ip", "node_nbt", "node_monitor", "node_wireless"].filter(table, call.request.sql.startsWith("UPDATE " + table + " ") || call.request.sql.startsWith("DELETE FROM " + table + " ")), "messages": call.response.messages})    }
} because "Node.pm:85-145 and native repository perform the same ordered writes and transaction boundaries with different SQL spelling/bind protocols; compare every command count, error and transaction response at its database, alongside exact full node rows and rollback checks"  exclude views.postgres
where (view.phase == "when" || view.step == "first") && view.request.sql == "UPDATE admin SET finished = $1, log = $2, started = $3, status = $4 WHERE ( job = $5 )"because "opaque poller localtime binds are checked as exact saved instants/status/log by the driver; full Admin state, queue identity, claim writes and node operations remain compared"  exclude views.postgres
where (view.phase == "when" || view.step == "first") && view.request.sql.startsWith("SELECT me.job, me.entered, me.started, me.finished")&& (view.request.sql.contains("FROM admin me") || view.request.sql.contains("FROM (  WITH my_jobs AS"))because "DBIC Admin read projections mix volatile queue clocks with JSON-looking subaction text; manager selection/claim, unchanged queue identity, exact saved status/log, started/finished, full Admin state and writes are independently checked; no node operations are excluded"  exclude views.postgres
where view.request.sql.startsWith("SELECT me.ip, me.creation") && view.request.sql.contains("age(LOCALTIMESTAMP")because "unused Device.with_times ages drift; full stored device identity, alias lookup, canonical completion, all node effects, errors and writes remain checked"  exclude views.postgres
where (view.phase == "when" || view.step == "first") && view.resource == "parity://connection/provider"&& view.request.sql in ["SELECT me.version FROM dbix_class_schema_versions me ORDER BY installed DESC LIMIT $1", "SELECT me.version, me.installed FROM dbix_class_schema_versions me WHERE ( 1=0 )"]&& view.response.messages.all(message, message.type != "error")because "DBIC lazily reads schema versions on its expiry connection; Rust does not use DBIC versioning; only successful metadata reads are omitted, with provider schema/state, failures and all node operations still compared"  require target views.local_ipc
where view.resource == "unix+stream:///tmp/netdisco-parity-worker.sock"      && view.phase == "when"
&& view.request.participants.exists(p, p.written.size > 0 && p.read.size > 0)because "in-bound expiry must be answered by the production Rust executor"  forbid target views.process
where view.actor.endsWith("/nd-worker-executor")because "Rust must not invoke legacy or any answer-producing process"differs at views.stdout.added.*.line on lines matching "^[{]\"legacy_workers\":(?:true|false)[}]$"because "native expiry must bypass every legacy phase; legacy executes its ordinary workers"  then {
assert views.stdout.added.filter(line, line.line.startsWith('{"legacy_workers":')).size() > 0assert views.stdout.added.filter(line, line.line.startsWith('{"legacy_workers":')).all(line, line.line == (subject == "legacy" ? '{"legacy_workers":true}' : '{"legacy_workers":false}'))  }
}
@expiry_retained = {
  exclude views.postgres
where view.phase == "given" && (view.request.sql.startsWith("SELECT me.username,") || view.request.sql.startsWith("UPDATE users SET token = md5(random()::text)"))because "authenticated fixture setup mints random login tokens and epochs; production queue input, user state, all when-phase calls and effects remain compared"  exclude views.postgres
where view.phase == "when" && view.request.sql == "UPDATE admin SET finished = $1, log = $2, started = $3, status = $4 WHERE ( job = $5 )"because "opaque poller localtime binds are checked as exact saved instants/status/log by the driver; full Admin state, queue identity, claim writes and node operations remain compared"  exclude views.postgres
where view.request.sql.startsWith("SELECT me.ip, me.creation") && view.request.sql.contains("age(LOCALTIMESTAMP")because "unused Device.with_times ages drift; full stored device identity, alias lookup, canonical completion, all node effects, errors and writes remain checked"  forbid target views.local_ipc
where view.resource == "unix+stream:///tmp/netdisco-parity-worker.sock" && view.phase == "when"because "unsupported inputs/plans remain entirely on legacy before executor output or writes"  then {
assert views.stdout.added.exists(line, line.line == '{"legacy_workers":true}')  }
}
@expiry_done = {
  then {
    assert result.exit_code == 0
assert views.stdout.added.exists(line, line.line.contains('"status":"done"') && line.line.contains('Expired nodes for '))assert views.stdout.added.exists(line, line.line.contains('"reference_log":false'))  }
}
@expiry_unchanged = {
  then {
    assert result.exit_code == 0
assert views.stdout.added.exists(line, line.line.contains('"unchanged":true'))  }
}
@tasty_native = {
  map observer_output {
    legacy {
notices = views.stderr.added.filter(line, line.line.startsWith("NOTICE:  parity_tastyjobs_write "))into [{"observer_only": notices.all(line, line.line.startsWith("NOTICE:  parity_tastyjobs_write "))}]    }
    target {
notices = views.stderr.added.filter(line, line.line.startsWith("NOTICE:  parity_tastyjobs_write "))into [{"observer_only": notices.all(line, line.line.startsWith("NOTICE:  parity_tastyjobs_write "))}]    }
} because "DBD::Pg prints fixture observer notices whereas rust-postgres does not; consume only those injected lines, whose full payloads are compared in preview_effects; all application output remains compared"  exclude views.postgres
    where view.phase == "when"
&& !(view.request.sql in ["ROLLBACK", "rollback"]|| (view.response.messages.size() > 0 && view.response.messages[0].type == "notice" && view.response.messages[0].fields.message.startsWith("parity_tastyjobs_write ")))because "DBIC/native SQL projections and connection ordering differ; write notices and rollback responses remain available to the identity mapping, alongside full output and database state"  map preview_effects {
    legacy {
writes = views.postgres.added.filter(call, call.phase == "when" && call.response.messages.size() > 0 && call.response.messages[0].type == "notice" && call.response.messages[0].fields.message.startsWith("parity_tastyjobs_write "))rollbacks = views.postgres.added.filter(call, call.phase == "when" && call.request.sql in ["ROLLBACK", "rollback"])assert writes.all(call, call.response.messages[0].type == "notice" && call.response.messages[0].fields.message.startsWith("parity_tastyjobs_write "))assert rollbacks.all(call, call.response.messages[0].tag == "ROLLBACK")into [{"writes": writes.map(call, call.response.messages[0].fields.message), "rollbacks": rollbacks.map(call, call.response.messages[0].tag)}]    }
    target {
writes = views.postgres.added.filter(call, call.phase == "when" && call.response.messages.size() > 0 && call.response.messages[0].type == "notice" && call.response.messages[0].fields.message.startsWith("parity_tastyjobs_write "))rollbacks = views.postgres.added.filter(call, call.phase == "when" && call.request.sql in ["ROLLBACK", "rollback"])assert writes.all(call, call.response.messages[0].type == "notice" && call.response.messages[0].fields.message.startsWith("parity_tastyjobs_write "))assert rollbacks.all(call, call.response.messages[0].tag == "ROLLBACK")into [{"writes": writes.map(call, call.response.messages[0].fields.message), "rollbacks": rollbacks.map(call, call.response.messages[0].tag)}]    }
} because "fixture statement triggers observe actual Admin and DeviceSkip identities and values before rollback; compare notices and rollback outcomes rather than SQL spelling or affected counts alone"  require target views.local_ipc
where view.resource == "unix+stream:///tmp/netdisco-parity-worker.sock"      && view.phase == "when"
&& view.request.participants.exists(p, p.written.size > 0 && p.read.size > 0)because "tastyjobs must be answered by the production Rust executor, not Perl fallback"  forbid target views.process
where view.actor.endsWith("/nd-worker-executor")because "the Rust executor must not spawn legacy or another answer-producing process"differs at views.stdout.added.*.line on lines matching "^[{]\"legacy_workers\":(?:true|false)[}]$"because "legacy runs its phases; a native target must not run the legacy callback"  then {
assert views.stdout.added.exists(line, line.line == (subject == "legacy" ? '{"legacy_workers":true}' : '{"legacy_workers":false}'))  }
}
@tasty_done = {
  then {
    assert result.exit_code == 0
assert views.stdout.added.exists(line, line.line == '{"log":"Showed the tastiest jobs","status":"done"}')assert views.tasty_completion.added.size() == 1 && views.tasty_completion.removed.size() == 1assert views.tasty_completion.added[0].row.contains('"status":"done"')assert views.tasty_completion.added[0].row.contains('Showed the tastiest jobs')assert views.stdout.added.exists(line, line.line == '{"preview_unchanged":true}')  }
}

Seen red first

A useful proof is one that was seen red first.

Proof only counts if it can fail.

Parity decides when a slice is done. A failing proof ends the run in a blocked draft pull request.

getapikey's #98 is one: 16 passed, 60 failed, and a body that opens with what is still wrong.

After a spec rewrite, the next run merged as #100.

dumpconfig took 12 Bosun commits and 24 human ones. We wrote the review findings as failing proof cases first.

getapikey, draft #98, as stated in the pull request
## Blocked: needs human intervention
parity verify --jobs 1 --seed 88256797364313 --report .parity/getapikey/diagnostic-full-final.json
16 passed, 60 failed, 0 errors
P1 — Reference-valued DBIC exception completion remains incompatible.
P2 — Configuration/provider string diagnostics still differ in result and persisted Admin log.
P2 — Stable legacy audit and end-to-end comparison remain unresolved.
The right end of the slice-run workflow in Bosun's task editor: Target E2E tests leads to Integrate and fix slice, which goes on success or failure to Challenge proof and review slice, with an edge looping back; from there one edge reaches Create draft pull request, and the edges marked on loop limit and on failure reach Create blocked draft pull request, both marked END.The right end of the slice-run workflow in Bosun's task editor: Target E2E tests leads to Integrate and fix slice, which goes on success or failure to Challenge proof and review slice, with an edge looping back; from there one edge reaches Create draft pull request, and the edges marked on loop limit and on failure reach Create blocked draft pull request, both marked END.
  1. 1Integrate and fix slice
  2. 2Challenge proof and review slice: on success, the draft pull request
  3. 3On failure or loop limit, the blocked draft
Every slice run ends in a draft pull request or a blocked draft.
dumpconfig, before and after the review fixes
  1. F1 dumpconfig.opaque-url-base URI::Based (url_base, present in every deployment) renders Data::Dumper bless( do{…} ) instead of legacy's GenericClass stringification…
  2. F2 dumpconfig.natural-key-order Hash keys sort ascii-lowercase; legacy uses Data::Printer natural sort…
  3. F5 dumpconfig.shared-reference-backref Shared references … are expanded by capture while legacy prints var{…} back-references
F1 dumpconfig.opaque-url-base
  FAILS (stderr differs)
F2 dumpconfig.natural-key-order
  FAILS (order differs)
F5 dumpconfig.shared-reference-backref
  FAILS (target succeeds with expanded text)
2 passed (hello), 27 failed, 2 skipped
F1 dumpconfig.opaque-url-base
  render stringification + ` (Class)`
F2 dumpconfig.natural-key-order
  implement `_nsort_pp` ordering
F5 dumpconfig.shared-reference-backref
  capture represents reference identity … and the renderer prints legacy's `var{…}` back-reference natively
F1–F7 and F10 are fixed and their proof cases are green.

Four slices merged on the fork

The goal is a change request that is clear in intent, functions as advertised, and includes evidence so we can merge with confidence, fast.

26,989 Lines merged
3.3 engineer-weeks, 0.2 FTE over 16 weeks
25.8k per engineer-week · whole run 8.3k · target 10k
Lines merged per engineer-week, four weeks at a time
target 10k
  1. 0 –
  2. 0 –
  3. 7.9k – #60
  4. 25.8k – #100 #101 #103

What this proves

  1. Can Bosun discover enough architecture and behaviour to identify a safe seam?

    Agents identify candidate seams, evaluate migration strategies against each other, and rank them. On 7 Aug we merged the Worker Execution Bridge (#42): one seam in the Perl runner that every migrated action crosses.

    Worker Execution Bridge, #42
  2. Can it produce a defensible migration decision rather than immediately translating code?

    We read the ranking and the proposed target and migration architecture in one document, so we can audit the whole decision.

    Architecture Decision
  3. Can it identify migration patterns and language quirks?

    For each category of code, patterns and data structures, agents write a list of legacy and target patterns, and we synthesise them into one guide every implementor uses.

    Migration Guide
  4. Can it identify seams: good first entry and end points?

    One agent identifies slices and another ranks them. dumpconfig headed the backlog; today it holds 58 slice contracts.

    Slice Backlog, 58 contracts
  5. Can it bootstrap a migration architecture?

    On 7 Aug we merged the Worker Execution Bridge (#42): one seam in the Perl runner that every migrated action crosses, with Perl as the fallback and the rollback path.

    Worker Execution Bridge, #42
  6. Can the migrated slices be compared with the legacy using executable specifications?

    Parity records what the process did, on both sides, and compares the lot. All 161 cases pass on the merged head.

    91 proofs, 161 cases
  7. Can change requests include enough evidence to minimise review time?

    #100 opened 20:42 UTC and merged 14:38 the next day with one human commit (+81/−25); #101 with three.

This comes down to trust, and auditability and proof build it.

Bosun ran this experiment on a Bosun-owned fork of the open-source Netdisco project. Netdisco did not commission or endorse the work. We are grateful for the work on Netdisco. We do not intend to migrate it in full.

Planning a legacy migration?

Start with one boundary and the proof needed to move it safely.