Factory docs, home
Page navigation

A names-only map of the third-party Services a Product uses and where their credentials live, and the answer to "can this need be met now, by which route?". It holds a frozen catalogue of Service kinds, scans a Product's tracked tree and captured listings for names, folds them into a Service map inside one journal command per discovery, keeps exclusive store bindings, owner declarations, confirmations and route pins, and answers readiness. It is built never to read, return or record a secret value; the canary, adversarial and regression suites establish that for the positions and forms they plant, which is not proof for every construct a repository could hold (Trust scope).

Status: Increment 1 of the service-access plan (<local evidence archive> §12; design accepted by Astra round 5 PASS, astra-design-r5.md in the same directory). Built on 29 September 2026 on branch factory/service-access from main at 985b5f9, then revised the same day on Fable 5.1's feedback (inc1/fable-feedback.md, dispositions in inc1/fable-dispositions.md) and on an adversarial test pass whose fourteen tests each found a defect, all fixed, with their classes closed by a regression suite (inc1/dispositions.md). Independent implementation review: Astra round 1 FAIL (inc1/astra-r1.md, eight findings, all reproduced and fixed with their classes, inc1/dispositions.md); Astra round 2 stopped at the Codex usage limit without a verdict (inc1/astra-r2.log). Under the owner's decision "Fable interim, Astra later" the increment is provisionally accepted — Fable 5.1 (max) round 1; Astra review pending (Codex usage limit until 2026-10-03 18:00): Fable 5.1 round 1 PASS (inc1/fable-verify/fable-r1.md) stands in until Astra reviews from 3 October 2026, when the highest-numbered inc1/astra-r*.md report holds the verdict. Fable's second finding, that absence does not yet account for organisation-level secrets and variables, stays open and must be closed before increment 2 reads any live repository. Proven on test-made fixtures only: no live listing, sign-in, repository or vault is read, no reader runs a process, and nothing composes the module. Receipt: service-access-receipt.json; build review row: build review. Local development only: E18 and E20, like every other epic, remain open. Human page: guide.

  • Source: src/service-kinds.ts (catalogue, grammars, shape checks), src/service-discovery.ts (path rules, scanners, listing and sign-in parsers, the fold), src/service-access.ts (aggregates, commands, views, readiness)
  • Tests: test/service-kinds.test.ts (4), test/service-discovery.test.ts (23), test/service-access.test.ts (23, one with six, six and four claimant processes racing), test/service-access-canaries.test.ts (2, with a negative control), test/service-access-boundaries.test.ts (3, import and identifier boundaries read from the compiler's syntax tree), test/service-access-adversarial.test.ts (14, written by the increment's adversary; never weakened), test/service-access-regressions.test.ts (14, one per class the adversarial tests and the implementation review opened)
  • Helpers: test/helpers/service-access.ts (a TreeSource that logs every read, gh-shaped captures, a synthetic Tally-shaped checkout), test/helpers/service-access-child.ts (one binding claimant per process)

Intelligence: none — Discovery, references, readiness and runbooks are exact rules over names and dates; a model would add an injection path near secrets, not accuracy.

What it hides

  • The catalogue. A ServiceKind is frozen data for one pure interpreter: id (vercel/1), family (ci-provider, saas, store-console, signing), pinned console origins, signatures (decisive or weak, by source: workflow file, action, command words, npm, SwiftPM, Gradle dependency, plugin or block, consumed name, any name), name prefixes, slots (class, exact names after a public prefix is stripped, destinations, desired scope), a 1Password item template, purposes (slots, routes, consumers, mutating, signatures), desired settings, runbook templates and brokered recipes (none yet). defineKind(family, spec) merges the family's defaults (item category, the five default settings, the four runbook templates), checks every rule, deep-freezes the kind and digests it (sha256: of the canonical kind without its digest). The catalogue's detection digest covers every kind's signatures, name prefixes, slot ids, classes and names, the public prefixes and the class rules. The first eleven kinds: github-actions/1, vercel/1, convex/1, clerk/1, posthog/1, sentry/1, honeycomb/1, app-store-connect/1, apple-signing/1, google-play/1, android-signing/1.
  • Path rules, deny first. isDeniedPath refuses .env* except *.example and *.sample, *.p8 *.p12 *.pfx *.pem *.key *.keystore *.jks *.mobileprovision *.gpg *.asc *.tfvars, id_rsa id_dsa id_ecdsa id_ed25519 .htpasswd GoogleService-Info.plist google-services.json .npmrc .netrc, *-firebase-adminsdk-*.json, and data files (JSON, YAML, text, plist, XML, properties, INI, TOML, config, CSV, .env or no extension) named with secret, credential or service-account: each is a secret-bearing-file finding, never opened. allowedRole opens only .github/workflows/*.yml|yaml, env templates, package.json (never under node_modules), *.gradle.kts, gradle/libs.versions.toml and Package.resolved.
  • The credential screen. credentialShaped(text) is true for a run of 20 or more letters and digits holding both (a random token, key or digest) and for a few vendor shapes whose random part is shorter or split (AWS key ids, Google API keys and OAuth tokens, GitLab and Slack tokens, Stripe webhook secrets), in either case. Every path, name and identifier from outside the Factory passes it before it may be recorded: RelPath refuses a path shaped like a credential, and the grammars for names, job ids, runner labels, instances, slugs, logins, attribution, held items and npm, SwiftPM, Gradle and action identities are screened (fits, matching; the rule text ends "not shaped like a credential"). Commit ids, digests, reference ids and 1Password ids are Factory-derived or vendor ids and are not screened. It is a screen, never a secret scanner: a name-shaped secret cannot be told from a name, and a long identifier with digits and no separator (LegacyApiV2IntegrationTests) is screened out too, a safe failure that leaves its source partial.
  • Tree scanning. scanTree(source, {capturedAt, catalogue?}) lists the TreeSource, applies the rules and reads only allow-listed blobs within maxFileBytes. A path outside RelPath (printable ASCII, relative, at most 200 bytes, no empty, . or .. segment, not shaped like a credential) is never opened or recorded; it is counted, and it makes the scan partial (credential-shaped-text when only the screen refused it, else path-outside-grammar), so a denied path shaped like a credential is not a secret-bearing-file finding either; the parser refuses a tree that counts one without either reason. Links, submodules, files over a per-file bound (the listed size before reading; lines, jobs, steps or sites once read: file-over-limit, the file dropped whole, never a prefix), size mismatches, unreadable and non-UTF-8 files, and denied paths the allow list would have opened also make it partial.
  • Scanners. A bounded line grammar for workflow YAML reads block mappings and sequences, one-line flow sequences of scalars, block scalars (literal | keeps its lines, folded > joins them), comments, and a plain scalar continued on deeper lines or written on the line after its key (folded into one value, as YAML folds it). It never reads a line as structure while inside a scalar or collection another line opened: a quoted scalar runs to its closing quote and a flow collection to its closing bracket, whatever the indentation between (as libyaml reads them), and both are folded into one value. Anchors and tags are stripped and their node read; aliases, merge keys, complex keys, flow mappings, nested flow collections, a quoted or flow value continued on later lines, text after a closing quote or bracket, a deeper line under a node the walker closed, and an expression opened on one line of a block scalar and closed on another are unsupported-yaml. A line over the length bound (line-over-limit), a tab in the indentation, a second document or content on a document marker stops the walker: nothing after that line is read. It keeps jobs with their lines, runner labels and kind (hosted by label pattern, else self-hosted; a dynamic label, or one outside the label grammar or screened, leaves the runner unknown, never hosted), the environment as an instance (lower-cased; dynamic, outside [a-z0-9._-]{1,64} or screened is unknown, and so is one not read whole: an environment key seen without its name, a job that is an alias or holds a merge key, or a job the walker stopped inside before its environment's name), every secrets.NAME and vars.NAME use with its line, job and exposure (env, with, run, secrets, if, other), forwards (--env, --build-env, -e, -b on a catalogue command such as vercel), secrets passed as command arguments through an environment variable, checkout tokens persisted (a secret token without persist-credentials: false) and matched signatures. A job whose id cannot be recorded is dropped with every line under it, never attributed to another job. An expression ${{ … }} ends at the first }} outside its quoted literals, and its references are read only outside them (secrets['NAME'] reads its index literal's text): a literal's text is a value, never a reference; an expression that never closes makes the file partial. Scripts are read as a shell reads them: ;, &, |, (, ), a newline and a command substitution ($( ) or backticks, inside double quotes too) start a command only outside quotes; single-quoted text, double-quoted text outside a substitution, a comment and a here-document body start none; a backslash-newline joins two lines; a forward is read from the provider command's own argument words (--env NAME=… or --env=NAME=…), so a flag inside another argument's quotes is that argument's text. GITHUB_TOKEN is the automatic token, never a stored secret. toJSON(secrets), a computed index, secrets: inherit and a name over 128 characters make the file partial (dynamic-reference); a workflow_call trigger in any form (a key, a list item, a flow collection) makes it a reusable workflow (reusable-workflow), whose secrets are its caller's; so does any job id, label, environment, call, name or identity the screen drops (credential-shaped-text), or a forwarded name outside the name grammar. Env templates keep key names with lines, reading values as dotenv does: a quoted value (double, single or backtick) runs to its closing quote across lines, and none of its lines is ever a key; where dotenv and a shell would read where a line ends differently (a quote opened inside an unquoted value or in a line that is no pair, text after a value's closing quote, an escaped quote in single quotes or backticks, a trailing backslash), where a value never ends, and at a line past the length bound, the key on that line stands and the scan ends there, partial. Manifests keep dependency identities (never URLs or versions) and signature hits: package.json and Package.resolved are parsed as JSON; a Gradle Kotlin script and a version catalogue (TOML) are lexed first, so a dependency, plugin or block is read only from code (a declaring call, or a catalogue key, whose value is a one-line string without a template), never from inside another string (a raw or multi-line one included) or a comment, and a source that ends inside a string or comment is partial. A literal where a credential belongs (any non-placeholder under a secret or signing-material name; a credential-like one under an unclassified name) is a literal-in-template finding by line; the value is dropped as it is read.
  • Listings and the sign-in. readListing(capture) takes what gh secret list or gh variable list --json name,updatedAt printed, the exit code, stderr already reduced by classifyStderr to a byte count and 401, 403 or null, and the endpoint's total_count. A failed command is unauthenticated (401) or unavailable (forbidden or failed); output that does not parse, an entry with any field but name and updatedAt, a name outside the grammar (a credential-shaped one included) or repeated in any case, or a bad date makes the listing corrupt and keeps none of it; complete needs the count to equal total_count, else partial. readSignIn(capture) keeps the active github.com account's login, state, scopes and token source from gh auth status --json hosts; a key named like a token (any key containing token but tokenSource) at any depth, any key besides the seven that gh 2.89.0 prints, or any of the seven holding another type than gh prints refuses the whole sign-in (a gh release that adds a key is refused until a reviewed revision lists it: a safe failure). No OAuth scopes, as a fine-grained or App token shows, keeps the sign-in with the reason scopes-unknown: whether it can write is unknown, never assumed read-only (writeCapable returns null).
  • The input. parseDiscoveryInput re-validates what the scanners and readers wrote, never trusting a status: a file scan must be of a path the deny list does not refuse and carry the scanner the allow list gives it; a tree's reasons must include every file's, and denied-allow-listed where a denied path would have been opened; a listing's status must agree with its total, entry count and reasons, and a sign-in's with its reasons and account, exactly as readListing and readSignIn write them. The fold re-checks the same where it matters, since it is exported: a listing is conclusive only when complete, non-empty, its total its entry count and without a reason, and a sign-in gives a reference only for an account the reader kept.
  • The fold. discoverServices(productId, input, catalogue?) is pure. Names resolve to a location per job: a job in an environment reads that environment's secret before the repository's, so only a conclusive listing of the environment settles which applies (the environment's when it lists the name, else the repository's); without one, the use is located at the environment, whose presence stays unknown, and also at the repository when its listing holds the name, so neither is taken for the one that applies and a stored repository name is never called unreferenced. A name is present when a listing holds it; absent only when every relevant listing is conclusive and every use of it shows where it resolves, observed as of the oldest of those listings; and unknown otherwise, with the listings' reasons and the uses': a use in a job whose environment is unknown (instance-unknown), in a reusable workflow (reusable-workflow) or in a file not scanned whole (tree-partial) never makes a name absent. Kinds are detected by one decisive or two distinct weak signatures. Names classify by slot after stripping a public prefix, else by one kind's name prefix (no slot) and the longest class rule (a public prefix makes an unmatched name public configuration, unless the rule says secret or signing material: such a name is a secret exposed to a client bundle, never one to move into a plain variable); two kinds claiming a name leave it unclassified with an ambiguous-kind finding. Each CI job records whether it names an environment (environment) and whether its workflow is reusable (called). Forwards become provider-variable references (presence provider-unreadable), template keys env-template-key references (local-not-a-source), and the ambient sign-in a tool-sign-in reference, with write-capable-reader only when a scope can write. A Service's evidence is capped for presentation at maxEvidencePerService sites, and evidenceTotal reports every distinct site found.
  • Records. Aggregates service-access:product:<productId> (sf-service-access/1: discoveries, route commands, the latest map, declared references, confirmations, route pins) and service-access:bindings (sf-service-access-bindings/1). The Product aggregate holds one Service map, so a Product binds at most one repository (maxRepositoriesPerProduct, 1) until maps are kept per repository; a second is refused (LIMIT) rather than silently replacing the first's map. Command IDs service-access:<productId>: followed by bind:github-<repositoryId> or bind:vault-<vaultId>, discover:<n>, declare:<referenceId>, confirm:<referenceId> or route:<n>; discover and route must be the next number (STALE otherwise). Events service-access.bound, .discovered, .declared, .confirmed, .route-pinned, one per command. Payloads are names only: the validated discovery input, never file text, raw output or a map.

Public interface

src/service-kinds.ts imports createHash from node:crypto and canonicalJson from ./canonical-json.ts. src/service-discovery.ts adds ./service-kinds.ts and the Portfolio's Count and CoverageStatus types. src/service-access.ts imports the other two, the journal class and its conflict, decision and input errors from ./journal.ts, and canonicalJson.

  • Constants: CATALOGUE_PROTOCOL = "sf-service-catalogue/1", TREE_SCAN_FORMAT = "sf-tree-scan/1", SERVICE_MAP_FORMAT = "sf-service-map/1", ACCESS_PROTOCOL = "sf-service-access/1", BINDINGS_PROTOCOL = "sf-service-access-bindings/1", BINDINGS_AGGREGATE. ACCESS_LIMITS (frozen): maxTreeEntries 100 000, maxOpenedFiles 400, maxFileBytes 262 144, maxTotalBytes 8 388 608, maxLineLength 4 096, maxLinesPerFile 20 000, maxJobsPerWorkflow 100, maxStepsPerJob 200, maxSitesPerFile 2 000, maxDenied 1 000, maxListings 64, maxListingEntries 1 000, maxCapturedBytes 1 048 576, maxReferences 2 000, maxConsumersPerReference 256, maxFindings 2 000, maxEvidencePerService 16, maxStateBytes 786 432, maxDeclared 256, maxConfirmed 512, maxPins 64, maxPinCommands 1 000, maxDiscoveries 100 000, maxBindings 1 000, maxBindingsPerProduct 16, maxRepositoriesPerProduct 1, defaultStaleAfterMs 86 400 000. KIND_LIMITS bounds a kind. Vocabularies: FAMILIES, CLASSES, CONSUMERS (producer and delivery are REFUSED_CONSUMERS), ROUTE_ORDER (ci, gh, brokered, owner), DESIRED_SETTINGS, RUNBOOK_PURPOSES, PUBLIC_PREFIXES, CLASS_RULES, PARTIAL_REASONS (with credential-shaped-text), LISTING_REASONS, SIGN_IN_REASONS (with scopes-unknown), UNKNOWN_REASONS (with reusable-workflow), FINDINGS (the spec's fourteen), LOCATION_STORES.
  • Catalogue: KIND_SPECS, defineKind(family, spec), defineCatalogue(kinds) (one revision per kind), KINDS, CATALOGUE, detectionRules, kindBase; the screen credentialShaped(text) and fits(value, grammar).
  • Discovery: isDeniedPath, allowedRole, scanTree, scanFile, isPlaceholder, classifyName, classifyStderr, readListing, readSignIn, writeCapable, parseDiscoveryInput, discoverServices, referenceIdOf (ref- and the first 24 hex of SHA-256 over canonical {productId, location}), locationName, parseLocation, parseServiceMap.
  • class ServiceAccess(journal, {catalogue?}):
    • bindLocation({commandId, productId, expectedVersion, binding, commissionedBy}), recordDiscovery({…, input}), declareReference({…, location, kind, instance, slot, declaredBy}), confirmReference({…, referenceId, kind, slot, confirmedBy}) and declareRoute({…, kind, instance, purpose, target, declaredBy}) return AccessResult {productId, aggregateId, commandId, version, replayed, event} or throw AccessError. expectedVersion is the bindings aggregate's version for bindLocation and the Product's otherwise; next(productId) gives both (NextCommands {version, bindingsVersion, discoverCommandId, routeCommandId}), with the next numbered ids, so a caller need not derive them from read. A concurrent command can still take a number first: the loser is STALE and reads again.
    • bindings(productId), read(productId), next(productId), map(productId, {asOf, staleAfterMs?}) (a ServiceMapView: the map with declarations, confirmations and pins merged, per-source staleness at asOf against the same Fresh bounds as readiness, drift against the catalogue in force, fixed unavailable coverage for the vault, provider stores and local files, and the Product's own bindings) and readiness(productId, need, {asOf, staleAfterMs?}). All reads work on a read-only journal, and every read re-validates the stored state (about 1 ms per call at the Tally-shaped fixture's scale, measured on this host; nothing is cached).
    • Helpers: productAggregateId, pinKeyOf, bindCommandId, discoverCommandId, declareCommandId, confirmCommandId, routeCommandId, parseAccessState.
  • AccessError {code, productId, currentVersion}, codes INVALID CORRUPT STALE CONFLICT LIMIT TAKEN EXISTS OUT_OF_BINDING NOT_FOUND CLASS ROUTE KIND; ShapeError (a TypeError) from the shape checks, <path> must <rule>.
  • Types: ServiceKind, Catalogue, TreeSource, TreeScan, FileScan, ListingCapture, ListingScan, SignInCapture, SignInScan, DiscoveryInput, Location, Presence, CredentialReference, ServiceEntry (with evidenceTotal), ServiceMap, CiJob, Finding, CountSet, StoreBinding, Need, Fresh, Route, Readiness (ready carries actionable), RoutePin, ServiceMapView, NextCommands.

Invariants and guarantees

  1. No value, by construction and on the tested forms. No interface is built to return a secret value and no record to hold one: deny-listed paths are never opened, only allow-listed blobs within the size limit are read, scanners keep names, lines, labels, identities and signature ids, no line inside a workflow or env-template value (a quoted or flow value continued on later lines, a plain scalar's continuation, a block scalar, an env template's quoted value) is ever read as a key, job id or name, no text inside a quoted string, an expression's literal or a comment of a Gradle script or catalogue is read as a dependency, reference or forward, a listing or sign-in with any unexpected field is kept as a status only, every string that enters a record matches a grammar, and every path, name and identifier from outside the Factory passes the credential screen. Test-made canaries, token-like and name-shaped, in every value position reach no byte of the database, WAL or shared-memory file, no event, view, answer or error, in raw, base64, base64url, hex, percent or JSON form or either case; the token-like canary is also planted where paths and names are recorded (an allow-listed and a denied path segment, a workflow file name, a job id, a runner label, an environment, a secret reference, a forwarded name, a template key, npm, Gradle and SwiftPM identities and a listed name) and is recorded nowhere; name-shaped canaries in multi-line quoted values, Kotlin raw strings, templates and block comments, TOML multi-line and literal strings, expression literals, a quoted provider flag and a template line a shell carries on are recorded nowhere either; a negative control shows the scan finds a canary a journal does hold, upper-cased as a name would be included (acceptance 2). This is evidence for those positions, not a proof that no other construct could carry a value into a name.
  2. Absence only from a conclusive listing and a use that shows where it resolves. A listing that is unauthenticated, forbidden, failed, incomplete, without total_count, malformed or empty leaves presence unknown; only a complete, authenticated, non-empty listing, whose total is its entry count, gives absent, referenced-not-stored and missing, and a listing whose status contradicts its own total or reasons is refused (INVALID) before any command. A use in a job whose environment is unknown, in a reusable workflow or in a file not scanned whole never makes a name absent. A partial tree keeps tree-dependent Counts from being exact and suppresses stored-not-referenced (acceptance 3). A consequence, by design: a Product with no secrets yet stays unknown: listing-empty and never reaches missing or an exact Count until an owner statement (increment 3) can say the store is empty.
  3. Counts are the Portfolio's. Only an exact Count shows 0: stored over the repository scope and every environment a job names, one named default included (Dependabot secrets serve Dependabot runs and are listed but not counted), referenced, storedUnreferenced (never a partial: it is an upper bound), referencedNotStored, and stored names by class, unclassified counted as secret. No Count is exact while a job's environment is unknown (instance-unknown) or a partial tree may hide jobs, and so environments (tree-partial: any tree reason but dynamic-reference, reusable-workflow or malformed-file, which leave every job read). On the Tally-shaped fixture: 44 stored, 31 referenced, 13 stored-unreferenced and 0 referenced-not-stored, all exact (acceptance 1).
  4. Exclusive bindings. One aggregate decides every claim: a slug (compared lower-cased), repository id or vault id another Product holds is TAKEN, the Product itself cannot bind its slug or vault again in another form (EXISTS), and a second repository for one Product is LIMIT (a stored one is CORRUPT). Six, six and four claimant processes racing on one slug in six cases, one repository id under six slugs and one vault id give exactly one winner each; losers re-read after STALE and are TAKEN. recordDiscovery needs its repository bound to the Product by slug and id; a declaration of a GitHub location or vault item outside the Product's bindings is OUT_OF_BINDING, whatever its reference id (acceptance 4).
  5. Isolation. Reference ids hash the Product with the location, so identical names in two Products never share an id; views and bindings return only the Product's own.
  6. Commands behave as the Product gate's. Input defects are INVALID before any journal work, except the check that a discovery's tree was scanned under the catalogue in force, which runs inside the decision (INVALID, recording nothing) so that an exact repeat still replays after the catalogue changes; an exact repeat of any command replays (replayed: true: bind, discover, declare, confirm and route are each tested); the same id with other content is CONFLICT; a wrong version or sequence number is STALE; stored state is re-validated on every read and command (CORRUPT, never repaired, including a forged reference id, a finding naming no reference, a miscounted evidence total, listing or sign-in coverage that contradicts itself, or a second repository binding); the next state is bounded (LIMIT above 768 KiB or the journal's document limit), a discovery input over the journal's 1 MiB document limit is LIMIT before any journal work, the declaration, confirmation and pin bounds are LIMIT, and a scan or fold beyond ACCESS_LIMITS's totals refuses with LIMIT, never truncating, while a single file over a per-file bound leaves the scan partial. A supplied map is INVALID (acceptance 5).
  7. Errors quote nothing. Every message names a field by path and the rule it breaks (or a fixed reason), never an input's content; the product id in an error is present only when it passed its grammar.
  8. Drift is per kind. A map records its detection digest and the digest of every kind it uses. Readiness is unknown: catalogue-drift when the detection rules or the need's own kind changed; a change to another kind's runbook changes nothing for this need (acceptance 5).
  9. Readiness (acceptance 6), in order: need grammar (INVALID); an unknown kind or purpose is refused KIND or PURPOSE; producer, delivery or a consumer the purpose does not list is CONSUMER; no map is unknown: not-discovered; a map any of whose sources (tree, listings, sign-in) was captured after asOf is unknown: captured-after-as-of, and drift and a tree older than its bound are unknown. Then routes in preference order (delegated CI, ambient sign-in, brokered, owner-executed): a delegated-CI destination is a job at exactly the need's instance that shows one of the purpose's signatures (preferred over a job that only consumes a slot) or consumes one of its slots; several are AMBIGUOUS unless a pin names one; a pin whose job is no longer a candidate is unknown: pin-target-gone; no candidate at the instance while a job's environment is unknown is unknown: instance-unknown. A job of a reusable workflow is unknown: reusable-workflow, since it reads its caller's secrets under names the caller maps. At the job, every slot must be consumed from the job's own CI stores (slot-not-consumed otherwise; a provider variable the job writes is a destination, not a source) by present references observed within the listings bound (stale-listing); any consumed reference of the slot whose presence is unknown, such as an environment that may override the repository, makes the answer unknown even beside a present one; for a job in an environment that environment's listing must be within the bound too; absent slots give missing only with a complete tree, and absence is as old as the oldest listing it rests on. The ambient sign-in route needs a present sign-in (sign-in-unavailable) observed within the listings bound (stale-sign-in). A brokered route counts only with a recipe (none exists in the catalogue; tests add one), and its destinations are vault item references (onepassword-ref) of the kind, instance and a purpose slot, since only a vault item is ever a brokered source: none falls through to the next route, several are AMBIGUOUS unless pinned, a pin whose item no longer fits is unknown: pin-target-gone, and one is unknown: vault-not-listed until vault presence and acquisition exist. The owner-executed route needs the kind among the map's Services and names its first console. No destination is unknown: no-route. Ready is presence, never validity, and carries actionable: false exactly for the owner-executed route, which only the owner can use; an actionable route still needs the grant its use requires (an Action grant for a CI run, a service grant for a brokered use).
  10. Classification is never lowered. confirmReference applies only to a reference whose kind is unclassified and refuses a slot whose class ranks below the reference's catalogue class (public configuration below identifier below secret and signing material); declareReference refuses (CLASS) a slot whose class ranks below the one a catalogue slot or name rule gives the location's name; and the view never applies a declaration or confirmation that would lower a class, whenever it was recorded (before discovery, or before a catalogue revision): a later catalogue classification wins, and a declared location the map does not hold shows the higher of its slot's class and its name's. There is no per-Product override of a catalogue classification: declareReference refuses a location the map holds (EXISTS) and confirmReference a classified one (CLASS), so a wrong name rule or slot name (OTEL_EXPORTER_OTLP_ENDPOINT claimed by honeycomb/1, the generic KEY_ALIAS) is fixed only by a reviewed catalogue revision.
  11. Route pins match their need. declareRoute accepts only a job that is a current delegated-CI candidate for the kind, instance and purpose, or for a brokered route a vault item reference of that kind, instance and one of the purpose's slots (a GitHub secret of the right slot is refused); the pin survives rediscovery, and the view shows it current or target-gone.
  12. Boundaries (acceptance 7). The three files import only each other in one direction (access → discovery → kinds), ./canonical-json.ts, ./journal.ts, node:crypto and the Portfolio's types; they name no process, fetch, globalThis, loader, timer or performance, call no import(), use no import.meta, and read no Date.now or Math.random; nothing else in src/ imports them. Probes in the compiler's view show each breach caught and comments and strings ignored.

Failure semantics

  • AccessError: INVALID for malformed input (a ShapeError becomes INVALID with its path and rule), before any journal work; rule failures throw their code and record nothing; VersionConflictError becomes STALE with the current version; CommandConflictError becomes CONFLICT ("re-read"); stored defects are CORRUPT; storage errors pass through.
  • scanTree and readListing refuse with AccessError LIMIT beyond their totals, and throw ShapeError for a malformed source listing or capture. scanFile alone refuses with LIMIT beyond a per-file bound, which scanTree turns into file-over-limit. Everything else about a file, listing or sign-in is its status and reasons.
  • readiness returns its four states and throws only INVALID, CORRUPT or a journal storage error.

Trust scope

  • Established locally (macOS arm64, Node 26.8.1, real SQLite journals in temporary directories, test-made fixtures): the path rules and read log, the scanners and parsers, the fold and its Counts on the Tally-shaped fixture, exclusive bindings under racing processes, replay, CONFLICT, STALE, CORRUPT, the byte and count bounds, drift, readiness, the canary scan, and the adversary's fourteen attacks (multi-line values, hostile YAML, forged scans, unknown environments, reusable workflows, classification, quoted commands, nested token keys) with the regression suite that closes each class.
  • Import and identifier boundaries: read from the pinned TypeScript 7.0.2's unstable JS API, as for the delivery core; loaders obtained at run time are not visible to it, so it guards against accidental coupling, not deliberately hostile source.
  • Not established: any live source. No reader runs git or gh (increment 2), vault presence is not read (increment 4), runbooks and blockers do not exist (increment 3), and no value-touching code exists (increments 5–9). The credential screen is a heuristic over shapes, not proof that no recorded name is a secret. The workflow grammar and the shell reading approximate libyaml and bash: what they cannot follow is partial or ends the reading, never guessed, but a construct neither anticipates may still be misread; a hostile repository can always write a name-shaped secret as a name. Every discovery's command stores its whole names-only input in the journal (about 22 KB for the Tally-shaped fixture), and nothing compacts that history; a tree scan kept once per commit is proposed for increment 2. Any catalogue revision changes the detection digest, so every Product's readiness is catalogue-drift until its next discovery. The Tally fixture is synthetic: it follows the re-checked facts in judging.md but is not Tally, and the live map (increment 2) may differ, for example where Tally's own syntax falls outside the line grammar. Detection is a catalogue of named signatures, not a secret scanner: history, other branches, provider-held variables, the Keychain and local files stay unknown. Organisation-level secrets and variables are not listed (spec decision 6), yet GitHub resolves a name from the environment, then the repository, then the organisation: in a repository an organisation owns, a name absent from the repository's and environment's listings may still be an organisation secret, and this increment's absence, referenced-not-stored and missing do not account for that level (an open question for the implementation review). Findings are proposals, never verdicts. Bindings, declarations, confirmations and pins are attribution that any process able to write the journal can forge (the combined model's trust scope); nothing here authenticates an owner.

Composition

  • Depends on: Command journal and canonical JSON; the Portfolio's Count and CoverageStatus types only.
  • Used by: nothing yet.
  • Planned (spec §12, not built): read-only readers under an owner commission (increment 2), runbooks with per-step checks and Portfolio blockers (3), vault presence (4), a Product gate revision for service grants (5), a confined executor profile with a loopback egress proxy (6), the separate credential-broker module (7), a first owner-granted live brokered read (8) and grant anchoring (9).

Changing it safely

  • Run node --test test/service-kinds.test.ts test/service-discovery.test.ts test/service-access.test.ts test/service-access-canaries.test.ts test/service-access-boundaries.test.ts test/service-access-adversarial.test.ts test/service-access-regressions.test.ts, then npm run check.
  • A kind change is a new revision (vercel/2) through defineKind, never an edit in place: its digest changes, stored maps show drift, and readiness for its needs is unknown until rediscovery. A change to any signature, slot name or class, name prefix or name rule changes the detection digest and so every Product's readiness until rediscovery.
  • A change to the credential screen changes what is recorded: rerun the canary suite, and treat a narrower screen as a security change for independent review.
  • Any change to a stored shape, a command ID, a key formula or the reference id changes recorded history: stored states must still parse or gain a versioned reader.
  • Reviewers check: nothing reads a value, no scanner keeps text from a value position or reads a line inside another line's value as structure, a scanner stops rather than guesses where parsers would disagree, every recorded path, name and identifier passes the screen, every scanner output parses as input (and nothing a scanner or reader would not write does), no decision does I/O or reads a clock, refusals record nothing, and every new bound refuses rather than truncates (a per-file bound drops the whole file).

Source: docs/agents/service-access.md