%%% title = "Keysingate Core: Channel Documents, Status Records and Anchored Time" abbrev = "KSG-Core" area = "General" category = "info" ipr = "trust200902" submissiontype = "IETF" keyword = ["delegation", "anchoring", "JCS", "canonicalization", "post-quantum signatures"] date = 2026-10-01T00:00:00Z [seriesInfo] name = "Internet-Draft" value = "draft-nam-ksg-core-00" stream = "IETF" status = "informational" [[author]] initials = "Y." surname = "Nam" fullname = "Yevhenii Nam" organization = "Keysingate" [author.address] email = "nam@keysingate.com" uri = "https://github.com/keysingate" %%% .# Abstract This document specifies the core of Keysingate: the canonical form and signature envelope of its documents, the time format and the rule that gives a signed object an upper time bound from public-network anchors, and the documents of the issuance channel -- release, delegation, block allocation, block closure, agent binding, container initiation, key inclusion, class promotion -- together with the status record that tracks a container's life. It is written so that two independent implementations produce the same bytes and reach the same verdict. The work journal kept inside a container, and its export, are specified in a companion document. {mainmatter} # Introduction The work journal and its export are specified in [@KSG-JOURNAL]. An issuer hands out ranges of serial numbers; distributors pass parts of them down a channel; a holder turns a serial into a container by binding an agent's key and an artifact to it at one moment. Every step is a signed document, and a third party verifies the whole chain -- from the issuer's release to the container's identifier -- without contacting the issuer and without trusting any operator's word about time. This document fixes what such verification needs to agree on: bytes, time, anchors, channel documents and status records. It does not specify how a container is built as a program, how keys are split or stored, which networks anchor, how they are read, or what the content of a journal page means. # Conventions and Terminology The key words "**MUST**", "**MUST NOT**", "**REQUIRED**", "**SHALL**", "**SHALL NOT**", "**SHOULD**", "**SHOULD NOT**", "**RECOMMENDED**", "**NOT RECOMMENDED**", "**MAY**", and "**OPTIONAL**" in this document are to be interpreted as described in BCP 14 [@!RFC2119] [@!RFC8174] when, and only when, they appear in all capitals, as shown here. Release: : A signed declaration of a range of serial numbers by an issuer (`Emission`). Block: : A range of serials sold to one holder, with a class and a term (`BlockAllocation`). Container: : The object created on one serial at initiation; its identifier is derived (Section "Identifiers"). Anchor: : Evidence from a public network that a subject existed no later than a moment. Network reader: : A component outside the core that, given an anchor of a kind it knows, confirms the anchor and returns the moment the network proves. Offset: : Milliseconds since a container's birth (`offset_ms`); not a date. # Canonical Form and Envelope ## Canonicalization Signed structures are JSON canonicalized with JCS [@!RFC8785]. A signature is computed over the UTF-8 bytes of the canonical form of the document **without** its `signatures` member. An absent optional member is omitted; `null` and absence are different documents. ## Numbers Every number in a signed document **MUST** be an integer in `0 ... 2^53 - 1`. A document containing a fraction, a negative number or a larger integer **MUST** be rejected before any signature is checked: only such numbers serialize to the same bytes in every JCS implementation. ## Required Members Every signed document of this core **MUST** carry `"@context": "urn:keysingate:core:v3"`, `"type"` naming the document and `"v": 3`. A member not defined for the document type **MUST** cause rejection: a document two implementations would read differently is accepted by neither. ## Document Hash `hash(doc) = SHA-256(JCS(doc without signatures))`, written as a multihash: the string `1220` followed by 64 lowercase hexadecimal digits. Uppercase digits **MUST** be rejected. # Signatures `signatures` is an array of objects `{kid, alg, value}`; `value` is base64url without padding [@!RFC4648]. | `alg` | Use | |---|---| | `Ed25519` | required; strict verification [@!RFC8032]: non-canonical encodings and small-order keys are rejected | | `ML-DSA-65` | optional [@!FIPS204]; only as a further element, never instead of Ed25519 | A verification profile names `required_algs` (default `[Ed25519]`), `min_anchors` (an integer >= 1, default 1) and optionally `now`. A document passes only if **every** required algorithm is covered by a valid signature of a key from the set given for that document. An unknown `alg`, a bad encoding, a `kid` outside the set, or a signature that does not verify **MUST** cause rejection -- a failing signature is a refusal, never a gap in coverage. # Time ## Form A timestamp is exactly `YYYY-MM-DDTHH:MM:SS.mmmZ`: UTC, three digits of milliseconds, 24 characters [@RFC3339]. Any other form -- no fraction, another fraction length, an offset, a leap second `:60` -- **MUST** be rejected. With one fixed form, the order of the strings is the order of the moments. ## Comparison Moments are compared as milliseconds since 1970-01-01T00:00:00Z, not as strings. The calendar is checked: month 1-12, day within the month, leap years. ## Offsets Inside a container, time is `offset_ms`. It **MUST NOT** be presented as a date. Only the bounds carry evidential weight: below, the release's `issued_at`; above, an anchor (Section "Anchors and the Upper Bound"). # Identifiers A party is identified by an opaque URI; the core does not interpret it. The container identifier is derived at initiation and re-derived at verification: ``` container = SHA-256( F("ksg:container-id:v1") || F(emission) || F(issued_at) || F(serial, 8-byte big-endian) || F(agent key) || F(artifact) || F(offset_ms, 8-byte big-endian) ) F(x) = (length of x, 4-byte big-endian) || x ``` where `emission` is the release id, `issued_at` the release's timestamp string, `agent key` the base64url key string of the client agent, `artifact` the artifact URI. The result is written as a multihash. A `ContainerInit` whose `container` differs from the derived value **MUST** be rejected: the identifier is evidence, not a claim. # Anchors and the Upper Bound ## Anchor ```json { "type": "", "subject": "1220...", "proof": "", "anchored_at": "..." } ``` The core does not parse `proof`. A network reader, selected by `type`, confirms that `subject` is recorded in the network and returns the moment the network proves. `anchored_at` is a statement, not evidence. ## The Bound Rule One procedure applies to everything that is anchored -- a journal seal, a status record, a release: 1. every anchor **MUST** have `subject` equal to the object's hash; otherwise reject; 2. an anchor of a kind for which the verifier has a reader **MUST** be confirmed; an unconfirmed one **MUST** cause rejection (a forgery is a refusal, not "not found"); 3. an anchor of a kind without a reader is skipped; 4. at least `min_anchors` (>= 1) anchors **MUST** be confirmed; with fewer, the object has **no** upper bound -- there is no bound "by an operator's word"; 5. the bound is the **earliest** moment among the confirmed anchors. ## Networks Which network anchors and how it is read is outside this document. A network profile **MAY** make an anchor blind -- visible to outsiders only as the fact of a write; the core sees only `subject` and `proof`. # Channel Documents All channel documents are major version 3 with the envelope of Section "Canonical Form and Envelope". Their JSON Schemas are published at `https://schema.keysingate.com/core/v2/` (Appendix "Schemas"). ## Release (Emission) Members: `id` (`ksg:em:` followed by digits), `range {from, to}`, `block_ttl_days` (>= 1), `issuer`, `issued_at`, `keys`, `signatures`. Self-signed. Trust goes only to keys present **both** in the release **and** in the verifier's own set of trusted issuer keys. `issued_at` is the lower time bound of everything under the release. **Lifetime.** A block may be opened for `base * (10 + d) / 10` milliseconds, where `base = block_ttl_days * 86 400 000` and `d` is the depth of the channel the block passed through (0 for a block sold by the issuer). Integer arithmetic, saturating. The result **MUST NOT** exceed 730 days (63 072 000 000 ms) whatever the release declares. The allowance per level compensates the time a block spends travelling down the channel; the ceiling bounds how long unopened stock may wait. ## Delegation The right to allocate blocks inside a sub-range. Members: `emission`, `range`, `delegate`, `keys`, `depth` (0-4), `parent` (hash of the delegation above; absent at depth 0), `delegated_at`, `term_ms`, `expires_at`, `signatures`. * `term_ms` **MUST NOT** exceed 180 days; `expires_at` **MUST** be later than `delegated_at` and **MUST NOT** be later than the parent's; * the range **MUST** lie within the granting party's range; * one invalid link invalidates the whole chain; * with `now` in the profile, an expired or not yet started link **MUST** cause rejection; without `now`, a report **MUST NOT** state that the authority was in force. ## Block Allocation Members: `emission`, `block {from, to}`, `class`, `holder`, `allocated_at`, `expires_at`, `prev_closure` (hash of the previous block's closure; **REQUIRED** from the holder's second block), `signatures`. * size by class: `heavy` 1-1 000; `light` 10 000-100 000; `bare` unbounded; * a holder's blocks are sequential and do not overlap; * a new block is allowed only once the previous one is used to at least 80 %, computed in integers (`used * 5 >= size * 4`); * signed by a release key or by the key at the end of a delegation chain; * the term belongs to the block: initiation before `expires_at` takes a container out of the term for good; after it, the container's status is 20; serials never initiated reach status 10. ## Block Closure Members: `emission`, `block`, `used_submitted`, `used_not_submitted`, `cancelled`, `closed_at`, `signatures` (the holder's). The three sets **MUST** cover the block exactly, without overlap; for `heavy`, `used_not_submitted` **MUST** be empty. ## Agent Binding Members: `emission`, `block`, `agent`, `bound_at`, `signatures`. A one-time binding of a block to the holder's agent key; a second binding of the same block is invalid. ## Container Initiation Members: `emission`, `serial`, `binding` (hash of the binding), `agent`, `artifact`, `offset_ms`, `container`, `signatures`. The client agent's key and the artifact are bound at one and the same moment. Signatures of the agent and of a second party (the owner or an orchestrator) -- at least two distinct parties. The serial **MUST** lie in the binding's block; the offset **MUST NOT** run past the block's lifetime; `container` **MUST** equal the derived identifier. ## Key Inclusion A key is never replaced; a new one is laid on top. Members: `emission`, `block`, `predecessor` (hash of the binding for the first, of the previous link after that), `key`, `offset_ms`, `signatures` (by the previous link's key). The valid set is the binding's key and every key of the unbroken chain; all stay valid. At most 1 024 links. ## Class Promotion Members: `container`, `from`, `to`, `offset_ms`, `signatures` (owner and issuer, at least two). Only upward: `bare` < `light` < `heavy`. # Classes A class is a tariff and the form in which a container's journal opens, not an anchoring mode for life: | Class | Journal opens as | May turn to anchored form | Anchors | |---|---|---|---| | `heavy` | anchored ("Pro") | -- | every seal | | `light` | unanchored ("lite") | yes, by a form-change page | only while anchored | | `bare` | unanchored | no | never | # Status Records A container's life is a sequence of statuses: | Status | Meaning | Status | Meaning | |---|---|---|---| | 0 | printed | 12 | opened voluntarily | | 1 | block applied | 20 | initiated after the term | | 2 | buyer's key applied | 21 | prescription passed | | 3 | sub-agent's key applied | 22 | key lost | | 4 | initiated | 30 | in dispute | | 5 | in work | 31 | dispute proven | | 6 | frozen | 32 | dispute unproven | | 7 | exported | 34 | appealed | | 9 | lost | 35 | settled | | 10 | never activated | 8, 33 | reserved, no transitions | Each change is a `StatusRecord`: `number` (0 for the printing, then +1), `status` (after the change), `event` (absent in record 0), `offset_ms`, `prev`, `signatures`. Record 0's `prev` is the section root: ``` root = SHA-256( F("ksg:status-section:v1") || F(emission) || F(serial, 8-byte big-endian) ) ``` -- the serial's address, known before the container exists. Every later record names its predecessor's hash. A change is valid only if the transition table allows it from the previous status (Appendix "Transition Table"); the table is normative and is also published as test vector `08_status_table`. A status record is anchored, and its anchor is read by the Bound Rule; without a confirmed anchor a status has **no** time, and a report **MUST** say so. **Status section export.** The release, the serial and every record with its anchors, in order. Not signed as a whole: each record is signed and anchored, and the chain from the root holds the order. Verification repeats acceptance: record 0 from the address, every next record through the transition table, linked, signed, anchored; then every anchor by the Bound Rule. # Verification Input: the object (a journal export, per the companion document, or a status section), the container identifier, the class or the channel it derives from, the keys, a profile, network readers. 1. limits (Section "Limits") -- before parsing; 2. parsing: version 3, strict members, numbers, time; 3. channel, if presented: release -> delegations -> block -> binding -> initiation -> container identifier; a closure of the block, if attached, **MUST** be of that block, signed by the holder, cover it without gaps or repeats and **MUST NOT** cancel this container's serial; without it, the report states that the completeness of the block is not established; 4. the object itself (companion document, or Section "Status Records"); 5. anchors by the Bound Rule; 6. the report. The report states accepted or rejected with a reason, and lists explicitly what is **not** established. A rejection is never replaced by a weakened result. # Versions Documents of this core are major version 3; an implementation of this core issues and verifies only that version. Adding an optional member is a minor change; changing the meaning or requiredness of a member is a new major version. # Limits | What | Limit | |---|---| | core document, bytes | 64 KiB | | journal export, bytes | 8 MiB | | signatures per document | 8 | | anchors per object | 16 | | Merkle path, links | 64 | | delegation links | 5 (depths 0-4) | | key inclusion links | 1 024 | | status section records | 1 024 | Exceeding a limit **MUST** cause rejection before any signature is checked: a verifier does not take on unbounded work. # Extension Rule Where a set can be accepted, do not choose: signatures, anchors and identifiers are arrays and opaque URIs. New things are added as elements, not by replacing a member. An extension **MUST NOT** change the meaning or requiredness of a core member. # Security Considerations **Time.** The only upper bound is a confirmed anchor. An implementation that falls back to `anchored_at`, to a server clock or to any operator's statement when no anchor is confirmed violates this document; Rule 4 exists to make that fallback impossible to express. **Fail-closed.** Anything unread, unknown or over a limit is a rejection. Strict member parsing and the integer rule exist because a document that two implementations read differently is an attack surface, not a compatibility issue. **Channel trust.** A release is self-signed; trust comes from the verifier's own set of issuer keys, never from the document. A delegation chain is only as valid as its weakest link, and without `now` no statement about authority in force is made. **Post-quantum.** ML-DSA-65 is added as a further signature, never as a replacement: an implementation without it still verifies Ed25519, and one requiring it states so in `required_algs`. **Implementation.** Verification keeps no state between calls, and library code of the core returns every error as a value rather than aborting. # Privacy Considerations Party identifiers are opaque URIs; the core neither resolves nor requires personal data. A container identifier is a hash and reveals the artifact only to someone who already holds it. Anchors may be blind (Section "Networks"): the network then shows only that something was written. # IANA Considerations This document has no IANA actions. `urn:keysingate:core:v3` is used as an opaque, byte-compared string; no URN namespace is registered by this document. {backmatter} # Schemas JSON Schemas of every document in Section "Channel Documents" and of the status record are published at `https://schema.keysingate.com/core/v2/` (`common`, `emission`, `delegation`, `allocation`, `closure`, `binding`, `container-init`, `key-inclusion`, `class-promotion`, `status-record`). A schema constrains shape, not rules: coverage, uniqueness, time bounds and signatures lie outside it. An implementation that has checked only the schema has not checked the document. # Transition Table Generated from the test vector `08_status_table.json`, which is generated from the reference implementation. Every pair not listed is refused. Events are those of the `StatusRecord` schema. | From | Event | Outcome | |---|---|---| | 0 | `apply_packet` | -> 1 | | 1 | `apply_buyer_key` | -> 2 | | 1 | `initiate_too_late` | -> 20 | | 1 | `packet_term_expired` | -> 10 | | 2 | `apply_sub_agent_key` | -> 3 | | 2 | `initiate` | -> 4 | | 2 | `initiate_too_late` | -> 20 | | 2 | `packet_term_expired` | -> 10 | | 3 | `initiate` | -> 4 | | 3 | `initiate_too_late` | -> 20 | | 3 | `packet_term_expired` | -> 10 | | 4 | `open_journal` | -> 5 | | 4 | `declare_key_lost` | -> 22 | | 4 | `declare_container_lost` | -> 9 | | 4 | `serve_dispute` | -> 30 | | 5 | `record_work` | stays | | 5 | `record_artifact_or_rights` | stays | | 5 | `transfer_ownership` | stays | | 5 | `raise_class` | stays | | 5 | `full_export` | -> 7 | | 5 | `finalize` | -> 6 | | 5 | `open_voluntarily` | -> 12 | | 5 | `freeze_on_owner_application` | -> 6 | | 5 | `succeed` | -> 6 | | 5 | `declare_key_lost` | -> 22 | | 5 | `declare_container_lost` | -> 9 | | 5 | `serve_dispute` | -> 30 | | 6 | `full_export` | -> 7 | | 6 | `open_voluntarily` | -> 12 | | 6 | `succeed` | stays | | 6 | `declare_key_lost` | -> 22 | | 6 | `declare_container_lost` | -> 9 | | 6 | `serve_dispute` | -> 30 | | 12 | `record_work` | stays | | 12 | `record_artifact_or_rights` | stays | | 12 | `transfer_ownership` | stays | | 12 | `raise_class` | stays | | 12 | `full_export` | -> 7 | | 12 | `finalize` | -> 6 | | 12 | `freeze_on_owner_application` | -> 6 | | 12 | `succeed` | -> 6 | | 12 | `declare_key_lost` | -> 22 | | 12 | `declare_container_lost` | -> 9 | | 12 | `serve_dispute` | -> 30 | | 30 | `record_work` | stays | | 30 | `record_artifact_or_rights` | stays | | 30 | `transfer_ownership` | stays | | 30 | `raise_class` | stays | | 30 | `full_export` | -> 7 | | 30 | `open_voluntarily` | -> 12 | | 30 | `freeze_on_owner_application` | -> 6 | | 30 | `succeed` | -> 6 | | 30 | `dispute_proven` | -> 31 | | 30 | `dispute_unproven` | -> 32 | | 30 | `extend_review` | stays | | 30 | `review_deadline_passed` | blocks | | 30 | `settle` | -> 35 | | 30 | `prescription_passed` | -> 21 | | 31 | `record_work` | stays | | 31 | `record_artifact_or_rights` | stays | | 31 | `transfer_ownership` | stays | | 31 | `raise_class` | stays | | 31 | `full_export` | -> 7 | | 31 | `open_voluntarily` | -> 12 | | 31 | `freeze_on_owner_application` | -> 6 | | 31 | `succeed` | -> 6 | | 31 | `appeal` | -> 34 | | 32 | `record_work` | stays | | 32 | `record_artifact_or_rights` | stays | | 32 | `transfer_ownership` | stays | | 32 | `raise_class` | stays | | 32 | `full_export` | -> 7 | | 32 | `open_voluntarily` | -> 12 | | 32 | `freeze_on_owner_application` | -> 6 | | 32 | `succeed` | -> 6 | | 34 | `record_work` | stays | | 34 | `record_artifact_or_rights` | stays | | 34 | `transfer_ownership` | stays | | 34 | `raise_class` | stays | | 34 | `full_export` | -> 7 | | 34 | `open_voluntarily` | -> 12 | | 34 | `freeze_on_owner_application` | -> 6 | | 34 | `succeed` | -> 6 | | 34 | `appeal_won` | stays | | 34 | `appeal_lost` | -> 31 | | 34 | `extend_review` | stays | | 34 | `review_deadline_passed` | blocks | | 34 | `settle` | -> 35 | | 34 | `prescription_passed` | -> 21 | | 35 | `record_work` | stays | | 35 | `record_artifact_or_rights` | stays | | 35 | `transfer_ownership` | stays | | 35 | `raise_class` | stays | | 35 | `full_export` | -> 7 | | 35 | `open_voluntarily` | -> 12 | | 35 | `freeze_on_owner_application` | -> 6 | | 35 | `succeed` | -> 6 | While a container is blocked awaiting a decision, whatever its status: | Event | Outcome | |---|---| | `dispute_proven` | -> 31 | | `dispute_unproven` | -> 32 | | `settle` | -> 35 | | `prescription_passed` | -> 21 | Terminal statuses: 7, 9, 10, 20, 22, 21. Working statuses: 5, 12, 30, 31, 32, 34, 35. # Implementation Status Per [@RFC7942]. `ksg-core-v2` (Rust) implements this document; with the crates around it the workspace runs 752 tests; 13 test-vector sets are regenerated and compared byte for byte by the reference build. The verifier `ksg-verify-v2` checks the channel from release to container identifier and status sections with anchors read through a Solana network reader. Keysingate Journal Export: Pages under a Portable Seal Key words for use in RFCs to Indicate Requirement Levels Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words JSON Canonicalization Scheme (JCS) Edwards-Curve Digital Signature Algorithm (EdDSA) Module-Lattice-Based Digital Signature Standard National Institute of Standards and Technology Date and Time on the Internet: Timestamps The Base16, Base32, and Base64 Data Encodings Improving Awareness of Running Code: The Implementation Status Section