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
ServiceKindis 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.
isDeniedPathrefuses.env*except*.exampleand*.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,.envor no extension) named withsecret,credentialorservice-account: each is asecret-bearing-filefinding, never opened.allowedRoleopens only.github/workflows/*.yml|yaml, env templates,package.json(never undernode_modules),*.gradle.kts,gradle/libs.versions.tomlandPackage.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 theTreeSource, applies the rules and reads only allow-listed blobs withinmaxFileBytes. 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 scanpartial(credential-shaped-textwhen only the screen refused it, elsepath-outside-grammar), so a denied path shaped like a credential is not asecret-bearing-filefinding 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 itpartial. - 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 areunsupported-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), everysecrets.NAMEandvars.NAMEuse with its line, job and exposure (env,with,run,secrets,if,other), forwards (--env,--build-env,-e,-bon a catalogue command such asvercel), secrets passed as command arguments through an environment variable, checkout tokens persisted (a secrettokenwithoutpersist-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_TOKENis the automatic token, never a stored secret.toJSON(secrets), a computed index,secrets: inheritand a name over 128 characters make the filepartial(dynamic-reference); aworkflow_calltrigger 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.jsonandPackage.resolvedare 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 aliteral-in-templatefinding by line; the value is dropped as it is read. - Listings and the sign-in.
readListing(capture)takes whatgh secret listorgh variable list --json name,updatedAtprinted, the exit code, stderr already reduced byclassifyStderrto a byte count and 401, 403 or null, and the endpoint'stotal_count. A failed command isunauthenticated(401) orunavailable(forbidden or failed); output that does not parse, an entry with any field butnameandupdatedAt, a name outside the grammar (a credential-shaped one included) or repeated in any case, or a bad date makes the listingcorruptand keeps none of it;completeneeds the count to equaltotal_count, elsepartial.readSignIn(capture)keeps the active github.com account's login, state, scopes and token source fromgh auth status --json hosts; a key named like a token (any key containingtokenbuttokenSource) 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 reasonscopes-unknown: whether it can write is unknown, never assumed read-only (writeCapablereturns null). - The input.
parseDiscoveryInputre-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, anddenied-allow-listedwhere 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 asreadListingandreadSignInwrite 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 ispresentwhen a listing holds it;absentonly when every relevant listing is conclusive and every use of it shows where it resolves, observed as of the oldest of those listings; andunknownotherwise, 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 anambiguous-kindfinding. Each CI job records whether it names an environment (environment) and whether its workflow is reusable (called). Forwards becomeprovider-variablereferences (presenceprovider-unreadable), template keysenv-template-keyreferences (local-not-a-source), and the ambient sign-in atool-sign-inreference, withwrite-capable-readeronly when a scope can write. A Service's evidence is capped for presentation atmaxEvidencePerServicesites, andevidenceTotalreports 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) andservice-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 IDsservice-access:<productId>:followed bybind:github-<repositoryId>orbind:vault-<vaultId>,discover:<n>,declare:<referenceId>,confirm:<referenceId>orroute:<n>;discoverandroutemust be the next number (STALEotherwise). Eventsservice-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):maxTreeEntries100 000,maxOpenedFiles400,maxFileBytes262 144,maxTotalBytes8 388 608,maxLineLength4 096,maxLinesPerFile20 000,maxJobsPerWorkflow100,maxStepsPerJob200,maxSitesPerFile2 000,maxDenied1 000,maxListings64,maxListingEntries1 000,maxCapturedBytes1 048 576,maxReferences2 000,maxConsumersPerReference256,maxFindings2 000,maxEvidencePerService16,maxStateBytes786 432,maxDeclared256,maxConfirmed512,maxPins64,maxPinCommands1 000,maxDiscoveries100 000,maxBindings1 000,maxBindingsPerProduct16,maxRepositoriesPerProduct1,defaultStaleAfterMs86 400 000.KIND_LIMITSbounds a kind. Vocabularies:FAMILIES,CLASSES,CONSUMERS(produceranddeliveryareREFUSED_CONSUMERS),ROUTE_ORDER(ci,gh,brokered,owner),DESIRED_SETTINGS,RUNBOOK_PURPOSES,PUBLIC_PREFIXES,CLASS_RULES,PARTIAL_REASONS(withcredential-shaped-text),LISTING_REASONS,SIGN_IN_REASONS(withscopes-unknown),UNKNOWN_REASONS(withreusable-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 screencredentialShaped(text)andfits(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})anddeclareRoute({…, kind, instance, purpose, target, declaredBy})returnAccessResult {productId, aggregateId, commandId, version, replayed, event}or throwAccessError.expectedVersionis the bindings aggregate's version forbindLocationand 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 fromread. A concurrent command can still take a number first: the loser isSTALEand reads again.bindings(productId),read(productId),next(productId),map(productId, {asOf, staleAfterMs?})(aServiceMapView: the map with declarations, confirmations and pins merged, per-source staleness atasOfagainst the sameFreshbounds as readiness, drift against the catalogue in force, fixedunavailablecoverage for the vault, provider stores and local files, and the Product's own bindings) andreadiness(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}, codesINVALID CORRUPT STALE CONFLICT LIMIT TAKEN EXISTS OUT_OF_BINDING NOT_FOUND CLASS ROUTE KIND;ShapeError(aTypeError) from the shape checks,<path> must <rule>.- Types:
ServiceKind,Catalogue,TreeSource,TreeScan,FileScan,ListingCapture,ListingScan,SignInCapture,SignInScan,DiscoveryInput,Location,Presence,CredentialReference,ServiceEntry(withevidenceTotal),ServiceMap,CiJob,Finding,CountSet,StoreBinding,Need,Fresh,Route,Readiness(readycarriesactionable),RoutePin,ServiceMapView,NextCommands.
Invariants and guarantees
- 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.
- 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 presenceunknown; only a complete, authenticated, non-empty listing, whose total is its entry count, givesabsent,referenced-not-storedandmissing, 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 suppressesstored-not-referenced(acceptance 3). A consequence, by design: a Product with no secrets yet staysunknown: listing-emptyand never reachesmissingor an exact Count until an owner statement (increment 3) can say the store is empty. - Counts are the Portfolio's. Only an exact Count shows 0:
storedover the repository scope and every environment a job names, one nameddefaultincluded (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 butdynamic-reference,reusable-workflowormalformed-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). - 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 isLIMIT(a stored one isCORRUPT). 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 afterSTALEand areTAKEN.recordDiscoveryneeds its repository bound to the Product by slug and id; a declaration of a GitHub location or vault item outside the Product's bindings isOUT_OF_BINDING, whatever its reference id (acceptance 4). - Isolation. Reference ids hash the Product with the location, so identical names in two Products never share an id; views and
bindingsreturn only the Product's own. - Commands behave as the Product gate's. Input defects are
INVALIDbefore 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 isCONFLICT; a wrong version or sequence number isSTALE; 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 (LIMITabove 768 KiB or the journal's document limit), a discovery input over the journal's 1 MiB document limit isLIMITbefore any journal work, the declaration, confirmation and pin bounds areLIMIT, and a scan or fold beyondACCESS_LIMITS's totals refuses withLIMIT, never truncating, while a single file over a per-file bound leaves the scan partial. A supplied map isINVALID(acceptance 5). - 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.
- Drift is per kind. A map records its detection digest and the digest of every kind it uses. Readiness is
unknown: catalogue-driftwhen the detection rules or the need's own kind changed; a change to another kind's runbook changes nothing for this need (acceptance 5). - Readiness (acceptance 6), in order: need grammar (
INVALID); an unknown kind or purpose isrefusedKINDorPURPOSE;producer,deliveryor a consumer the purpose does not list isCONSUMER; no map isunknown: not-discovered; a map any of whose sources (tree, listings, sign-in) was captured afterasOfisunknown: captured-after-as-of, and drift and a tree older than its bound areunknown. 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 areAMBIGUOUSunless a pin names one; a pin whose job is no longer a candidate isunknown: pin-target-gone; no candidate at the instance while a job's environment is unknown isunknown: instance-unknown. A job of a reusable workflow isunknown: 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-consumedotherwise; 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 givemissingonly 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 areAMBIGUOUSunless pinned, a pin whose item no longer fits isunknown: pin-target-gone, and one isunknown: vault-not-listeduntil vault presence and acquisition exist. The owner-executed route needs the kind among the map's Services and names its first console. No destination isunknown: no-route. Ready is presence, never validity, and carriesactionable: 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). - Classification is never lowered.
confirmReferenceapplies only to a reference whose kind isunclassifiedand refuses a slot whose class ranks below the reference's catalogue class (public configuration below identifier below secret and signing material);declareReferencerefuses (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:declareReferencerefuses a location the map holds (EXISTS) andconfirmReferencea classified one (CLASS), so a wrong name rule or slot name (OTEL_EXPORTER_OTLP_ENDPOINTclaimed byhoneycomb/1, the genericKEY_ALIAS) is fixed only by a reviewed catalogue revision. - Route pins match their need.
declareRouteaccepts 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 itcurrentortarget-gone. - Boundaries (acceptance 7). The three files import only each other in one direction (access → discovery → kinds),
./canonical-json.ts,./journal.ts,node:cryptoand the Portfolio's types; they name noprocess,fetch,globalThis, loader, timer orperformance, call noimport(), use noimport.meta, and read noDate.noworMath.random; nothing else insrc/imports them. Probes in the compiler's view show each breach caught and comments and strings ignored.
Failure semantics
AccessError:INVALIDfor malformed input (aShapeErrorbecomesINVALIDwith its path and rule), before any journal work; rule failures throw their code and record nothing;VersionConflictErrorbecomesSTALEwith the current version;CommandConflictErrorbecomesCONFLICT("re-read"); stored defects areCORRUPT; storage errors pass through.scanTreeandreadListingrefuse withAccessErrorLIMITbeyond their totals, and throwShapeErrorfor a malformed source listing or capture.scanFilealone refuses withLIMITbeyond a per-file bound, whichscanTreeturns intofile-over-limit. Everything else about a file, listing or sign-in is its status and reasons.readinessreturns its four states and throws onlyINVALID,CORRUPTor 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
gitorgh(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 ispartialor 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 iscatalogue-driftuntil its next discovery. The Tally fixture is synthetic: it follows the re-checked facts injudging.mdbut 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 stayunknown. 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-storedandmissingdo 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
CountandCoverageStatustypes 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-brokermodule (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, thennpm run check. - A kind change is a new revision (
vercel/2) throughdefineKind, never an edit in place: its digest changes, stored maps show drift, and readiness for its needs isunknownuntil 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).
