Exchange file artifact
This document specifies the downloadable exchange file: the alcove.yaml a party composes in the web application and hands to the CLI. It covers what the artifact is (the shared CLI config schema, not a parallel format) and the mint-layer guarantees layered on top of it. It also covers the versioning and compatibility policy between a continuously-deployed web app and a pinned CLI, how the agreed terms hold each party's payload columns and the run-time gates that enforce them, the channel-binding semantics an accepting tool must honor, and the path the shared secret takes (never the file), and the terms update that changes an established partnership's terms without it. It is the implementation-level complement to the field-level exchange reference, which an operator opens to author or read an alcove.yaml, and to the Provisioning the key file from an invitation material under that document's Authentication section; this document covers how the artifact is constructed and what it does and does not promise. It does not cover the field-level meaning of any config field (see EXCHANGE_REFERENCE.md), the invitation token's wire format and connection-endpoint sub-schemas (see INVITATION_TOKEN.md), or the owner-only write discipline the key file it provisions is written under (see CREDENTIAL_STORAGE.md). Intended readers are security auditors and implementors.
The artifact is the CLI config schema
A minted exchange file is an ordinary alcove.yaml. There is no web-specific format, no parallel schema, and no field a CLI-authored config could not also hold. The web mint layer (mintExchangeFile in packages/core/src/config/exchangeFile.ts) expands the credential-free locator into a connection block and serializes the parse result -- not the pre-validation input -- through the same snakeizeKeys + YAML stringify discipline serializeExchangeDocument (packages/core/src/config/exchangeDocument.ts) applies, the function the CLI's saveConfig (apps/cli/src/config.ts) calls to write its output.
Assembly and validation are one function below the mint, assembleExchangeSpec in the same module: it builds the exchange from the camelCase parts, attaching each optional key only where the caller supplied a value, and returns what ExchangeSpecSchema (packages/core/src/config/exchangeSpec.ts) -- the schema both applications share -- parsed rather than what it was handed. That single assembly rule, and not the mint, is what more than one artifact rests on: the web app's managed-record composer (apps/web/src/psi/managed/managedExchangeRecord.ts) assembles the exchange document it persists through the same function. So the downloadable file and the browser-persisted record cannot become two shapes of one exchange, and the guarantees below hold for both. The output is snake_case on disk, the convention every hand-authored config follows, and the CLI's loadConfig (apps/cli/src/commands/exchange.ts) reads it through parseExchangeSpec (which camelizeKeys then re-validates through the identical schema) with no web-aware step.
Serializing the parse result rather than the assembled input is a structural guarantee, not a convention: only the schema's own fields reach the YAML, so a caller that bypassed the TypeScript types could not smuggle an extra top-level field into a minted file. A validation failure throws a ZodError on the minting side, so a malformed exchange never becomes a downloadable file that the CLI would later reject.
Mint-layer guarantees
The schema is the shared contract; the mint layer adds three guarantees a hand-authored config is not obligated to meet. They are guarantees about what a minted file cannot contain; how its terms hold each party's payload columns, and what enforces them, is in Payload-disclosure consent below.
- No
authenticationblock. The mint layer never assembles the top-levelauthenticationblock at all. The schema makes that block optional and gives it three fields:shared_secretandexpires, both key-file-injected at runtime, and the operator-policytoken_max_age_days, which an operator sets inalcove.yamland no mint path has a value for. So a minted file that omits the block has no secret and no place to put one, and leaves the max-age policy to whoever runs it. This mirrorsserializeExchangeDocument(packages/core/src/config/exchangeDocument.ts), which stripsshared_secretandexpiresfrom anyauthenticationblock a caller leaves populated -- and leavestoken_max_age_daysstanding, the same as whensaveConfig(apps/cli/src/config.ts) calls it to write its output; the mint layer reaches the same end by never building the block. - No credential field is representable. The mint layer's input connection is a credential-free locator type (
ExchangeFileConnection:SftpExchangeLocatororFiledropExchangeLocator). By construction these types have nousername,password,privateKey,privateKeyPassphrase,hostKeyFingerprint, orkeyboardInteractivefield, so a credential cannot reach a minted file even by mistake -- the type is the enforcement, not a runtime strip. WebRTC is outside this type: a WebRTC exchange is coordinated live, not from a downloaded file, so the mint layer covers only the file-sync channels (sftp,filedrop). - The SFTP placeholder username. An SFTP locator has no identity field, so a minted SFTP connection seeds the one SSH identity field the operator must supply,
username, with the shared placeholder constantPLACEHOLDER_SSH_USERNAME=REPLACE_WITH_SSH_USERNAME(packages/core/src/config/endpointProducer.ts). The placeholder is not a valid credential:alcove exchangerefuses a configuration that still holds anyREPLACE_WITH_value before it connects, naming the field -- hereconnection.server.username, unless--server-usernamereplaces it for the run. The same constant is used by the CLI'sconnectionFromEndpointand by the templatealcove initwrites, so the "fill this in" marker, and the refusal, are identical wherever a config was minted.
Writing a configuration back
A tool that reads an alcove.yaml and writes one -- the console opening the configuration in its mounted folder, editing it, and handing back the file a scheduled command-line run loads (apps/web/src/jobs/handoff.ts) -- writes it through serializeExchangeDocument, the same function saveConfig calls. What that promises, and what it does not, is bounded:
- Byte-stable: a parse result's key order and the guidance comments. The writer is handed a spec that came out of
ExchangeSpecSchema, so its key order is the schema's declaration order and its connection block carries the commentsannotateConnectionGuidanceattaches. Two callers handing it the same parse result write the same bytes, and a document it wrote re-writes to itself. - Not round-trippable: the file's own presentation. An operator's own comments, their key order, their quoting and flow style, and a
tabor\tcsv_delimiterspellingnormalizeCsvDelimiter(packages/core/src/config/exchangeSpec.ts) folds to the character are properties of the file rather than of the document, and a read-and-write cycle states the document. The promise is a file byte-compatible with what Alcove writes for those settings, not with an arbitrary hand-authored file. - A CLI-written file is not itself in that order.
alcove inviteandalcove accepthandsaveConfiga spec they assembled rather than one the schema parsed, so reading one of their files and writing it back states every setting at the same value with keys reordered inside a block. Measured against the built CLI inapps/web/test/interop/consoleExportParity.test.ts. - A default a writing surface states is written. The console's connection-tuning card states a value for every setting it offers, so a run composed from a file that left one unset writes it explicitly, at the value it already had.
- Not loaded: two
roleandis_payloadpairs. Ametadatacolumn stated asrole: payloadwithis_payload: false, orrole: ignoredwithis_payload: true, has no choice in the console's column step, so the console reportsmetadataas not loaded and writes that column back asrole: ignoredwithis_payload: false, sending it exactly when the file's own pair does. Measured inapps/web/test/unit/exchange/inviterConfigurationLoad.test.ts.
Versioning and compatibility policy
The hosted web application is continuously deployed; a CLI in the field is pinned to whatever @alcove/core version its release shipped with. A newer web app can therefore mint a file whose schema is newer than an older CLI's. The policy below is the direct consequence of the schema mechanics, not a separate promise layered over them.
What a minted file targets
A minted file targets the ExchangeSpecSchema of the @alcove/core version the web app shipped with. Both applications embed the same schema; the artifact is valid against the version that produced it. That is the whole promise on the compatibility axis.
What an older CLI does with a newer file
The observable outcome depends on where the field diverges, and the cases differ sharply:
- An unknown top-level key is rejected loudly.
ExchangeSpecSchemais az.strictObject, so a key the older CLI's schema does not know -- whether a newer web app's addition or an operator's typo -- is reported fromloadConfigas a load-timeUsageError(CLI exit 64) naming the key, and the exchange never starts. One top-level key is an enforcement record whose absence is a valid state (expected_partner_deduplicate), so stripping a misspelling of it would silently disable the control it names; that hazard governs the whole top level rather than being spot-checked key by key.alcove invite,alcove updateandalcove verify-receipt --partner-termsread only the blocks they use, so they do not run this check: an unknown top-level key, a retired one included, is refused by the file's next whole-file load (alcove exchange,alcove apply), not by them. - An unknown field inside a spec block is rejected loudly too. The blocks themselves (
linkage_terms,metadata,standardization,connection) strip unrecognized keys on parse, theconnectionunion's webrtc member excepted (below): they are built from different Zod constructs -- an object forlinkage_terms, an array formetadataandstandardization, a discriminated union forconnection-- and strip is the behavior they share.parseExchangeSpecdoes not leave it there. It compares the document against its own parse result and reports every key the parse did not read as anunrecognized_keysissue at the block holding it, naming the key in the file's own snake_case spelling (packages/core/src/config/unreadKeys.ts), so an older CLI reads a newer web app's added field as a load-time failure rather than running one field short. What makes the strip unacceptable on its own is the writer side: a consumer that loads a file, edits part of it, and saves it writes the PARSE RESULT, so a stripped key is gone from the next file too (the rule below). - An unknown enum value is rejected loudly. A field whose value changed to one an older schema does not accept -- a new
algorithm, a newlinkage_strategy, a new semantictype, a newchannel-- is az.enum/z.literalthe older schema rejects.loadConfigreports theZodErroras a load-timeUsageError(CLI exit 64) naming the field. The exchange never starts. - The
authenticationblock and a webrtcconnectionare validated strictly. Like the top level and unlike the sibling spec blocks,AuthenticationSchemaand theconnectionunion's webrtc member arez.strictObjects: an unrecognized key is rejected, not stripped, because each holds an operator control a typo must not silently disable --token_max_age_days, which bounds token age, andallowed_relays, which decides which relays this party contacts. A newer application that adds a webrtc connection field is therefore rejected by an older CLI rather than narrowed, the trade the top level already makes. A minted file never includes theauthenticationblock, and a locator-composed webrtc connection holdsserverand, where the invitation named a relay,invitation_relay-- which a CLI predating that field rejects.
The critical property across all four cases: an incompatibility is reported as a loud load-time validation error, never a silent reinterpretation of a value into something it did not mean and never a run over a subset of what the file states.
What is not promised
There is no back-compatibility guarantee for existing artifacts. Breaking changes to the config file format are explicitly in scope: a future core version may change field shapes or semantics, and a file minted by one web version is not promised to load unchanged under a differently-versioned CLI. The compatibility mechanics above are what keeps such a break visible -- an incompatible file fails validation with a named field rather than loading with a misread value -- not a promise that the break will not happen. An operator who downloads a file should run it against a CLI of the matching generation, and re-mint (or re-invite) rather than hand-migrate a file across a breaking change.
What a consumer does with a setting it cannot honor
A configuration is a configuration: one file, valid against one schema, readable wherever Alcove runs. Three applications read it -- the CLI, the console, and the web application -- and they do not run the same exchanges or offer the same editors, so a file valid against the schema can name a setting the application in front of it cannot run.
This is a different question from the version-skew cases above, and the two are settled separately: there the field is one the BUILD does not know, here it is one the build knows and the CONTEXT cannot run.
What each application does with each setting it reads is tabulated in EXCHANGE_REFERENCE.md.
The three outcomes
A consumer reading an exchange file MUST take exactly one of these for every setting the file states.
- Honor it. The setting is read and applied, or it is enforced -- the ordinary case, and the only one that needs no words to the operator.
- Hold it unchanged. A consumer that cannot apply or edit a setting but can keep it MUST write it back out with the value it read, and MUST state which settings it holds without an editor, so the operator knows what this surface will not let them change and where to change it instead.
- Refuse the load. A consumer that can neither apply nor hold a setting refuses the whole document, naming the setting and the limit that stops it -- as the web application refuses a connection credential it would have to store, such as an SFTP
private_keywritten as a value rather than an@path.
Dropping a setting is not among them. A consumer MUST NOT load a document with a setting silently discarded, and MUST NOT write a document short of a setting the one it read stated. That rule is what the unread-key refusal above enforces for a key no schema block reads, and it is why saveConfig and the web application's export write a document holding every field their input held. A document writing one setting under two spellings of its key -- the snake_case the file writes and the camelCase the parse works in -- is refused on the same rule, by the case conversion ahead of the schema, which would otherwise keep only one of the two.
The record that must survive
One per-party record is fail-closed: expected_partner_deduplicate, the terms-side enforcement record (Terms-binding consent). Its ABSENCE is a valid state that turns the control off, so a consumer that loses it silently disables an enforcement the operator wrote.
A consumer that would drop it refuses the load instead. It has no "hold it unchanged" option to fall back on: holding a record it cannot enforce is the same failure, one exchange later.
How a setting is named
Every refusal and every notice names the setting as the FILE spells it: the block PATH holding it is given in snake_case (connection.server.port, linkage_terms.deduplicate) regardless of how the file spelled that block, while the KEY at the end of the path is given as the document itself spells it, rather than the camelCase of the parsed shape. The key a refusal names is the key the document holds, so one written in camelCase or outside either convention is named its own spelling: a reader that converted the parsed name back to snake_case would name a line the file does not hold. A name is all that is named -- a setting's VALUE can be a credential and stays out of both.
Payload-disclosure consent
Both payload directions are agreed terms. linkage_terms.payload.send names the columns a party sends and payload.receive the columns it receives, and the terms exchange compares each party's receive against the send set the other party's run states, before any key or data moves (EXCHANGE_REFERENCE.md, PROTOCOL.md). No local record beside the terms holds either set, and the invitation token and the terms update state the columns only in their terms.
The set a party's run states as its send is disclosedColumnNames(metadata) -- the names isDisclosedToPartner selects and preparePayload transmits -- never a separately authored list, so the set compared at the terms exchange is the set the payload step sends. A received payload is not compared against payload.receive again after it crosses. The one check after the payload exchange is for a party that receives no payload at all (The no-output send gate).
Absent, empty, present
The three states of payload.receive are distinct, and the distinction is the disclosure control:
- Absent compares nothing. A recurring run fills it on its first run (An unset
payload.receiveis filled on the first run); a one-off run takes what the partner sends. - Present and empty is strict, and is not the absent case.
[]asserts "nothing", and a partner stating any column is refused at the terms exchange. - Present and non-empty is strict in both directions: a narrowed set is refused exactly as a widened one is, because the exchange record and the partner's consent surface state the agreed set.
Laziness relaxes only the declaration check, never what is disclosed. Transmission stays governed by each sender's own isDisclosedToPartner metadata, so a lazy receiver still receives only what the sender's metadata transmits.
How a party arrives at its list, by exchange mode:
- Invite/accept. Every mint states the inviter's
payload.sendfrom its metadata (The send side is in the agreed terms), and the acceptance mirrors it into the acceptor'spayload.receive(deriveAcceptedLinkageTerms), so the list the acceptor consented to is the one every later run compares. An inviter whose ownpayload.receiveis unset fills it on its first run. - Zero-setup. Neither party holds the other's metadata in advance, so the first exchange compares nothing. Each party infers its metadata in direct mode, so its send set is every input column whose name inference does not recognize as a linkage key (DEFAULT_STANDARDIZATION.md). A
--saverun fillspayload.receivefrom the partner's declared send set and writes it into the config it saves. It writes its own inferred metadata there too, so the recurring run that follows states the same send set. - Recurring. Both parties' configurations hold both lists, and each run's terms exchange compares them.
The send side is in the agreed terms
No local record holds a party's send set. Every mint -- alcove invite online, offline from an input file, or from a configuration, and alcove update -- states payload.send as the columns its metadata discloses (termsStatingDeclaredPayloadSend), and writes the list into the configuration it writes or reuses where that configuration leaves it unset or names other columns (A stated payload.send that differs from the metadata). An acceptor's mirrored payload.receive (deriveAcceptedLinkageTerms) therefore holds the inviter's send set inside the agreed terms, which the agreed-terms hash covers. A mint from a configuration with no metadata block states nothing, since the set is known only from the input file the mint does not read; the partner's receive list is then filled on its first run.
At every run a party states its send set from its metadata at the terms exchange, an acceptor included, so a send set that changed on either side meets the partner as a terms change before any key or data moves (PROTOCOL.md).
A stated payload.send that differs from the metadata
Every mint -- alcove invite, alcove update, the web re-invite and the web terms update -- and every run states payload.send as the columns the metadata discloses (termsStatingDeclaredPayloadSend) in place of a present list naming other columns, keeping a description authored for a column in both. alcove invite and alcove update rewrite the configuration's list to the stated one before printing, with a warning naming the file; the web re-invite and terms update state it from the stored document's metadata. A partner holding the earlier list meets the difference as a terms change at the terms exchange (PROTOCOL.md).
The web app's first mint (generateInvitation) refuses the pair instead (assertPayloadSendDisclosed), as a check on the terms its own editor composed, which derives payload.send from the same metadata. That check holds a present payload.send to exactly the disclosed set in both directions; its empty case is gated on this party's own output.share_with_partner, since with the partner entitled to no result nothing crosses whatever the metadata discloses.
The no-output send gate
The payload channel is gated on output entitlement, closing the one-sided disclosure analyzed in one-sided-disclosure.md: runExchange transmits payload only to a partner whose agreed terms entitle it to the result (output.expects_output), so a non-receiving helper -- which learns no matched records -- is sent no payload. The receive side fails closed as a safety check: a party with expects_output: false, and either party to a count-only run, aborts as a ConnectionError of kind protocol (CLI exit 76) if it is sent any payload column regardless (assertNoPayloadReceived). The check runs after the payload exchange, where the exchange record is already owed, so an aborted run still writes a record, marked terminated (see EXCHANGE_RECORD.md). This is enforced in the protocol, alongside the schema rule forbidding a no-output party from declaring payload.receive columns; it is left neither to the data dictionary nor to operator discipline.
A party that receives payload holds what arrives to the partner's agreed payload.send, compared as a sorted list by exact name, so a repeated name is refused even beside every agreed one: a column the list does not name, or a listed column left out, aborts the run the same way, at the same point and with the same record. The refusal message names no column; the terminated record still commits the payload as received, so its governance.payloadReceived holds the received column names. A run in which none of the partner's rows matched receives no payload columns at all, and that passes.
An unset payload.receive is filled on the first run
A party states, in the terms it sends at every terms exchange, the columns its metadata discloses (termsStatingDeclaredPayloadSend, packages/core/src/payloadExchange.ts), whether its terms leave payload.send unset or name other columns in it; a present send naming exactly those columns, an explicit empty list included, is sent as authored. A count-only (psi-c) document and a party with output.share_with_partner: false state nothing, since no payload moves to the partner under either. The stated form is the one validateCompatibility compares and the agreed-terms hash covers, so a verifier recomputing the hash from a configuration states it from that configuration's metadata the same way, or, for a configuration with no metadata block, from the input header the run inferred its metadata from (termsAsTheRunStatedThem). The hash also resolves an unset payload.receive to the partner's stated send set, the list the fill below records (The agreed-terms hash).
A recurring run whose terms leave payload.receive unset, and whose partner can send it payload (payloadReceiveFillsOnFirstRun, packages/core/src/config/recurringTerms.ts), fills the list from the partner's stated send once the terms exchange's refusals have all passed -- the receipt bindings, the partner-certificate pin, the matching cardinality, the count-only shape and the payload-disclosure directions -- and before the bootstrap frame and any linkage key or payload row moves (onPayloadReceiveFilled, runExchange). A run refused at the terms exchange records nothing.
Where the partner's stated send names at least one column, a caller may confirm the columns first (onPayloadReceiveFill, runExchange). The confirmation runs after the terms exchange and before the partner-certificate pin, so a decline records neither the pin nor the fill, and the refusals listed above still apply after the operator confirms. A decline sends the partner the abort reason PAYLOAD_RECEIVE_NOT_ACCEPTED_REASON; the declining party sends none of its linkage keys or payload rows and records no fill. The partner, which passed the terms exchange before the confirmation began, may already have sent its first-round blinded set, which discloses nothing about its records. A confirmation that fails rather than answers sends a fixed reason naming this party's own failure instead, so the partner does not read it as a decline. The CLI confirms at a terminal and takes the columns without asking otherwise (CLI.md).
The application driving the exchange records the list in the document it runs from: the CLI in its alcove.yaml (persistFilledPayloadReceive), the web app in the saved recurring record. A failure to record it stops the run, with an abort frame to the partner, before anything is disclosed. A one-off run records nothing and leaves the direction lazy. The next run compares the recorded list strictly, so a later change to either side's payload is a terms mismatch at the handshake.
Only a run conducted from a recurring document fills: a CLI run from its alcove.yaml, or a web managed run of a saved recurring exchange. An exchange the web app conducts from the invitation flow does not fill, even when the operator saves the record before that exchange ends; the saved record's first managed run fills it.
An explicit receive: [] states "receive nothing" and is not filled. A document CLI writes for an exchange with the list unset says, where the key would go, that the first run fills it (annotateUnsetPayloadReceive, packages/core/src/config/exchangeDocument.ts), and the write that records the list removes that comment.
Terms-binding consent
One top-level key states a commitment about the partner's terms rather than its payload: expected_partner_deduplicate, a sibling of linkage_terms and a boolean. It records the deduplicate an accepted invitation declared for the inviting party's own side. It is per-party and local -- never exchanged, cross-checked, or folded into the agreed-terms hash -- and it is enforced at run time rather than merely recorded.
runExchange holds the value the partner presents at the terms exchange to it (assertPresentedDeduplicateMatchesInvitation, packages/core/src/exchange/termsRefusals.ts) and aborts on a contradiction as an InvitationTermDivergenceError -- a ConnectionError of kind protocol, CLI exit 76 -- before any key or payload moves. The refusal is one-sided by construction: only the accepting party holds the declaration.
Two states, not three: the empty case has no analogue here.
- Absent means no binding is enforced, not that only a file with nothing to bind reaches this state. It is the state of an exchange authored from two parties' own configuration files, where the differing pair is exactly what makes one of them the "many" side, and no acceptance ever had a value to record. Two further paths reach it despite an acceptance: the online-accept reuse branch warns and continues when persisting the declaration to an existing config fails ("...and to no value if it records none"), and a config written by a build predating the field has none to read back.
- Present binds strictly,
falseno less thantrue. A declaredfalseagainst a presentedtrueis the widening the record exists to refuse.
The field is distinct from linkage_terms.deduplicate beside it, which is this party's own side: deriveAcceptedLinkageTerms takes an acceptance's own value from the accepting party -- false where its seat offers no control -- and retains nothing of the inviter's, so a run that read the binding off its own terms would refuse the legitimate differing pair. The two are read from separate keys and never derived from one another.
Every path that reaches an acceptance records it: the CLI's offline accept writes it into the config it composes, the online accept includes it in the bootstrap's config write and refreshes a reused config in place, the browser's managed deposit persists it into the record's document, and a console server-job accept forwards it into the composed config. alcove apply writes the deduplicate a terms update states for its sender, in the same write that adopts the update's terms. A later run restores it from the config onto prepared.expectedPartnerDeduplicate. No mint path records it: an inviter accepted no declaration.
Terms update
A terms update is the artifact that changes an established partnership's linkage terms without a new invitation (alcove update mints it, alcove apply consumes it; operator behavior in CLI.md). It is implemented in packages/core/src/config/termsUpdate.ts.
Relation to the invitation token
The two artifacts state the sending party's terms the same way, and differ in what else they hold:
| Invitation token | Terms update | |
|---|---|---|
| Linkage terms | the inviter's, validated by the same camelizing schema | the sender's, validated by the same schema |
| Shared secret | the setup secret | none |
| Connection endpoint | optional locator | none |
| Expiry | optional | none |
| Integrity | a 4-byte checksum against transcription errors only | HMAC-SHA-256 under a key derived from the shared secret |
A terms update therefore cannot establish or re-establish a partnership: without the secret its holder cannot authenticate a key exchange, and without an endpoint it names no rendezvous. It is not confidential in the way an invitation is. Its reader learns the linkage terms, the payload column names among them, not a credential.
The applying party derives its own terms from the update with deriveAcceptedLinkageTerms, exactly as an acceptance derives them from an invitation, with one difference: its own deduplicate is passed through from the configuration it already holds, where an acceptance takes false.
Wire format
The encoded update is BODY.MAC, two base64url strings joined by .. Both are unpadded: the decoder refuses an update holding any = character as a format refusal, before it decodes either part:
BODYencodes the UTF-8 bytes of a JSON object with exactly the keyskind(the stringterms-update),version("1"),partnership, andlinkageTerms. The object is strict: any other key is refused, including thedisclosedPayloadColumnsan earlier build wrote.MACencodesHMAC-SHA-256(mac_key, BODY bytes), 32 bytes, computed over the exact bytesBODYencodes, so no canonical re-serialization is involved.
The whole string is bounded by the invitation's encoded-length bound (MAX_ENCODED_INVITATION_LENGTH), checked before any decoding.
Both derived values come from the decoded 32-byte shared secret, with the application HKDF (hkdfDerive: HKDF-SHA-256, zero salt) under labels in the domain-separation label space:
partnership = base64url(HKDF(secret, "alcove-terms-update-v2:partnership", 16))
mac_key = HKDF(secret, "alcove-terms-update-v2:mac", 32)The partnership identifier is a one-way function of the secret both parties hold, so neither needs anything beyond its key file to compute it, and it reveals nothing about the secret. It follows the secret: every rotation gives the partnership a new identifier, so an update made before an exchange no longer matches after it.
Verification order
decodeTermsUpdate checks, in order, and refuses with TermsUpdateRefusedError naming the check:
- Format: the length bound, the
BODY.MACshape, no=padding, base64url, and a 32-byte MAC. - MAC: the tag is recomputed over the body bytes and compared in constant time, before the body is validated or anything in it is returned. Where it fails, the body is read only far enough to find a
partnershipstring: one that differs from the local identifier is refused aspartnership(another partnership, or a secret replaced since), anything else asauthentication(altered content). - Schema: the authenticated body is validated as above; a failure is refused as
format. Where the failing body stateskindterms-updateand a decimalversionabove"1", the refusal says the update was made by a newer Alcove; this reading is gated on the MAC having verified, so an unauthenticated body is never reported that way. An authenticated body whosepartnershipdiffers from the local identifier is refused aspartnership.
The CLI then refuses, before any display, an update whose terms name the applying party's own identity, since that is the applying party's own update.
What applying writes
Applying rewrites linkage_terms, and expected_partner_deduplicate to the update's deduplicate, in one atomic write (persistTermsUpdate, apps/cli/src/config/termsUpdate.ts), after reading the edited document back through parseExchangeSpec; a document that would not load is refused and the file left unchanged. The columns this party receives are the update's payload.send, mirrored into payload.receive by deriveAcceptedLinkageTerms.
Minting an update states payload.send on the rule an invitation minted from the same configuration follows (The send side is in the agreed terms). Neither command writes the key file or any key of the connection block.
Channel-binding semantics
An invitation may bind a connection endpoint (the credential-free ConnectionEndpoint locator, packages/core/src/config/invitation.ts) so the accepting party can reach the rendezvous without separate out-of-band setup. An endpoint names exactly one channel, and the accepting party's tool must be able to drive that channel. There is no cross-transport promise and no renegotiation: what a build can drive is fixed at build time, and an endpoint it cannot drive is refused rather than adapted.
The drivable set is not a property of "browser versus CLI" but of the tool and, for the web application, its deployment profile:
- CLI.
sftp,filedrop, andwebrtc.alcove acceptseeds the endpoint into the acceptor's connection through the single consumerconnectionFromEndpoint(apps/cli), which also applies the mirror swap for a split-directory endpoint. A seededwebrtcconnection holds the locator only; the accept path stampsrole: acceptoronto it (withWebRTCPeerRole), and the channel needs the shared secret both parties derive their rendezvous ids from, soalcove exchangerefuses a webrtc run that reaches it without a secret or without a stampedrole. - Browser, hosted profile.
webrtconly. A file-sync endpoint names a directory or an SFTP host the browser cannot reach. - Browser, console profile.
webrtc,filedrop, andsftp. The console build runs a file-sync exchange through its job API rather than in the page, so a file-sync endpoint is drivable there and is accepted.
Enforcement lives at the acceptor's accept path. In the browser, prepareAcceptedInvitation (apps/web/src/psi/acceptInvitation.ts) admits an endpoint only through endpointDrivableHere(endpoint, profile), a switch over the channel union that is exhaustive with no default -- so a newly added channel fails to compile until it is classified, the allowlist discipline rather than a blocklist that would admit an unvetted channel. A token with no endpoint, or one this build cannot drive, throws before any rendezvous is attempted:
This invitation does not include a connection endpoint this build can accept, so it cannot be run here.
Because every failure path throws, a caller that only dials on success cannot reach across transports.
The endpoint itself holds only a public locator (signaling URL, SFTP host/port/path, or a file-drop directory / split pair) and never a credential; the per-channel sub-schemas are z.strictObject and reject any field outside the locator allowlist. The endpoint wire format is specified in INVITATION_TOKEN.md, and the split-directory mirror swap in FILE_SYNC.md.
The secret's path
The shared secret rides only the invitation code. It never enters the exchange file, and each party provisions its own .alcove.key from the code:
- The file has no secret. As above, a minted file has no
authenticationblock, andserializeExchangeDocument(packages/core/src/config/exchangeDocument.ts) stripsshared_secret/expiresregardless, whether the caller issaveConfig(apps/cli/src/config.ts) or another writer. The 256-bit setup secret an invitation holds (SHARED_SECRET_REGEX: 43 base64url characters encoding 32 bytes,packages/core/src/config/connection.ts) is confidential and travels only on the encoded invitation code, over a trusted out-of-band channel. - The three provisioning paths. An offline
alcove invitewrites the inviter's key file (secret plus expiry), while an onlineinvitewrites it only at the terms exchange that follows the partner's handshake, once the terms are agreed on, holding the rotated token with no expiry;alcove acceptwrites the acceptor's copy (secret, with the invitation expiry stripped); andalcove exchange --invitation CODEprovisions the key file for the party that composed the exchange in the web app and downloaded a secret-free config, writing the inviter-side copy (secret and expiry, matchingalcove invite) so the invitation's bounded lifetime is enforced at exchange time.
The key file schema
KeyFileSchema and serializeKeyFile (packages/core/src/config/keyFile.ts) define the .alcove.key fields for every reader and writer: the CLI, the console's check of a mounted key file, and the web application's import and command-line export. A reader drops a field it does not know rather than refusing the file, and the serializer writes only the known fields, as pretty-printed JSON with a trailing newline. The web application's import notice names each dropped field to the operator, by name only and never its value; a name containing a secret-shaped run of base64url characters is replaced by a placeholder.
The rotation-in-flight marker
The key file holds a third, optional field beside sharedSecret and expires: rotationInFlightSince, an ISO 8601 UTC instant. It records a key exchange that began and has not saved its rotated secret.
- Write order.
runProtocol(apps/cli/src/protocol.ts) writes it throughmarkRotationInFlight(apps/cli/src/keyFile.ts) after the transport to the partner is open and before the key exchange starts, under the same owner-only, atomic-rename write as the rotation itself, and only when the run is the responder: the initiator stores the next secret first, so only the responder can be left behind (PROTOCOL.md). The rotated-secret write the terms exchange makes (buildRotatedKeyFile) holds no marker, so the one atomic write that stores the new secret removes it. A marker already present keeps its first instant, and stays until a recorded outcome supersedes it, whichever role this run takes. A write that fails stops the run before the key exchange, with the shared secret unchanged; the file may still hold the marker, since the write renames into place before it flushes the directory. - What supersedes it. A recorded outcome supersedes the marker: a key exchange that fails closed (a
"security"-kindConnectionError) removes it through the same write before the run reports that failure, and so does a run that completed its key exchange and ended at the terms exchange with neither side able to have stored the next secret -- before the responder sent terms message 2, or on the initiator's abort in place ofproceed, which the initiator sends before any store. A crash, or a run that ends before its key exchange completes or after terms message 2 on anything but that abort, leaves it. - Where it is not written. It is written only over a key file holding the secret the run authenticates with, so the online
inviteandaccept, whose key file is written at the terms exchange, write none. - What it means. A key file still holding it at the next run records a run that stopped, as the responder, between the start of its key exchange and its save: the partner, which stores first, may have saved a secret this file does not hold.
alcove exchangestates that at load, as a warning naming the instant, the re-invite remedy, and the confirm-first step for an authentication failure neither party's interruption explains. It is evidence for the benign desync reading only beside the authentication failure or no-show it predicts, and it changes no exit code and refuses nothing. - What it does not change. The file still holds exactly one live secret; no previous secret is kept and the handshake is unchanged.
- Across applications. The console's check of a mounted key file admits the field, and the CLI run it starts reads the marker as above. A key file imported into a managed exchange beside an
alcove.yamlis admitted and its marker dropped, since an import is read through the import marker instead (see MANAGED_EXCHANGE_RECORD.md). A key file the web application writes holds no marker.
The pending relay registration
The key file holds a fourth, optional field: relayRegistrationPendingSince, an ISO 8601 UTC instant. It records that the secret the file holds has not been confirmed at the relay registrar a run registers at (PROTOCOL.md, Registering the rotated relay key).
- Write order. A run that registers its relay key stores it in the rotation write itself (
authenticateRuninapps/cli/src/protocol.ts), so no moment exists at which the file holds the rotated secret without it. A registration the registrar confirms removes it throughclearRelayRegistrationPending(apps/cli/src/keyFile.ts), under the same owner-only, atomic-rename write; so doesalcove enroll-relay. - What it holds. An instant only. The pre-rotation secret and every key derived from either secret stay out of the file.
- What it means. A key file still holding it at the next run records a registration that run confirms before it dials; a refusal there ends the run with exit 77 and the re-enrollment step, with the secret unchanged.
- What supersedes it. A run that does not register -- its connection names no registrar -- leaves the field, and its own rotation write, which stores none, drops it.
- Across applications. The console's check of a mounted key file validates the field as the command line does, so it reads the file and refuses one whose field is not an ISO 8601 instant. The web application's import of a command-line key file carries the field onto the managed record it installs, so that exchange's first browser run retries the registration, and its command-line export writes the field from a record's own pending registration, so the first command-line run retries it (MANAGED_EXCHANGE_RECORD.md).
exchange --invitation fail-closed ordering
keyFileFromInvitation and saveInvitedKeyFile (apps/cli/src/keyFile.ts) are the ordering authority for that path, and it is fail-closed at each step:
- Refuse if a key file already exists. A key file present at the key path is a
UsageError(exit 64), never an overwrite. After the first exchange the secret rotates, so re-supplying the original code must not resurrect a stale secret; provisioning is a first-time step, re-established only by re-inviting. This check runs first, before the code is even decoded. - Decode and validate before the configuration loads. The code is decoded and validated for checksum, schema, and expiry (
decodeAndValidateInvitation), so a malformed or expired code raises itsUsageErrorwith nothing written and no connection attempted. - Run on the decoded key, then write it. The decoded shared secret and expiry are held in memory, and the run takes them in place of the key file it would otherwise read. The key file is written (the token's shared secret and expiry) only once every check the CLI makes before connecting has passed, just before the run first contacts the network: after the configuration load, the dataset preparation, the signing identity load, the run's local pre-flight, and the wake call's own validation, and before the wake call, the host-key probe, or the dial. A run refused before then leaves no key file. The write is an exclusive create, so a key file that appeared at the key path meanwhile is refused as in step 1 rather than overwritten. Limits: an SFTP private key the transport cannot parse is refused only at the dial, after the key file is written; a refused run can also leave the key file's parent directory and the output folder the pre-flight created; and the pre-flight does not probe the hard-link support the exclusive write uses, so on a filesystem without it the write fails after the pre-flight, leaving no secret on disk. The
--invitationvalue is never@-resolved intoargv; its@-file form (--invitation '@code.txt') is read at decode time, keeping the code out of shell history and the process argument list.
The owner-only on-disk write discipline the key file is written under -- the POSIX exclusive-create, atomic-rename, and fsync durability that keeps a freshly written or rotated token from being lost or world-readable -- is specified once in CREDENTIAL_STORAGE.md and is not repeated here. The secret rotation the key file undergoes after each successful handshake is covered in SECURITY_DESIGN.md.