@flashyos/countersign
v0.1.0
Published
Verification over a directory/1 record: a countersignature from the party a claim is about, an audit of what a website can prove, and a badge that carries a date. Ed25519 over the canonical assertion, verifiable offline from two published keys.
Downloads
99
Readme
@flashyos/countersign
Proof over a directory record. @flashyos/directory says what the estate
knows — one record per real thing, federated as fragments each repository emits.
This package is the half that record layer has no opinion about: a
countersignature from the party a claim is about, an audit of what any website
can currently demonstrate, and a badge that carries a date and can age.
Phases 00 to 08 of Folio GH-DIR-01 (Revision D) (docs/directory/the-directory.md).
The normative profile is SPEC.md. The schemas and the
conformance suite are served at the URLs their own $id fields claim, so an
implementation in any language can fetch them without importing anything of
ours:
| | |
|---|---|
| flashyos.com/schema/directory/node.schema.json | A record |
| flashyos.com/schema/directory/edge.schema.json | A dated, attributable relationship |
| flashyos.com/schema/directory/signatures.schema.json | The countersignature sidecar |
| flashyos.com/schema/directory/conformance.json | The suite — data, not code |
Those URLs 404'd until they were routed, which meant every schema pointed an
implementer at nothing and the claim above was false for anybody outside this
monorepo. route.test.ts in the marketing app now fails if a schema is
published without being served.
npm run build -w @flashyos/countersign
node dist/cli/bin.js resolve org/metaverse-group # read the record back
node dist/cli/bin.js fetch metaversegroup.com # read somebody else's
node dist/cli/bin.js init example.com # start your own
node dist/cli/bin.js validate directory.json # every problem, not the first
node dist/cli/bin.js grade directory.json --as-of 2027-06-01 # the number a stranger can recompute
node dist/cli/bin.js audit gda.group # what does this site claim?
node dist/cli/bin.js invite ... # ask a counterparty to sign
node dist/cli/bin.js countersign ... # apply what came back
node dist/cli/bin.js verify doc.json --jwks keys.json # check every countersignature
node dist/cli/bin.js conformance # 23 cases, pass or failThere is no seed command. Reading seventeen sibling checkouts to build one
central document was this package's original design and it has been retired —
@flashyos/directory federates instead, for the reason quoted at the bottom of
this file.
Two wire formats, one well-known path
/.well-known/directory.json carries either of two shapes, and
readDirectoryDocument() reads both:
| Wire | Shape | Who emits it |
|---|---|---|
| directory/0.1 | Flat nodes and edges, each edge naming its from | A repository, via @flashyos/directory |
| directory/1 | Edges embedded under the node asserting them | Anything carrying countersignatures |
The second exists because a signature covers an assertion together with the
node making it, and that pairing is what gets sealed. The first exists because
a repository can emit an edge without owning the node at either end, which is
what makes federation work. Neither is deprecated. fromFragment is the one
conversion between them.
This was not always true, and the way it went wrong is worth keeping: the audit
accepted only directory/1. gda.group serves a directory/0.1 fragment with 27
dated, attributed assertions, 16 of them cross-boundary — and our own audit
reported "no dated assertions published" about our own flagship, because we
had shipped two readers for one path. document.test.ts reads that real served
file rather than a fixture, so the next format change is caught by the thing it
would break.
Unreachable is not the same as empty
A site we could not read grades unreachable, never none. The distinction is
the whole reason the grade exists: "this site does not appear to assert a
relationship with anyone" is a statement about somebody else's site, and making
it because their host had a bad afternoon — or because we were blocked — is
publishing a falsehood about a third party under the banner of an audit.
It renders neutral, offers no remedy, and says plainly that the finding is about our attempt rather than about them. There is nothing to sell to somebody whose site we did not read.
When a site publishes at two paths
The estate serves its record at /.well-known/directory.json and, since the
estate-graph work, at /directory.fragment.json as well. A reader cannot know
which a given site treats as canonical, so the audit reads both and counts a
claim published at either — de-duplicated, so one claim at two paths is one
claim.
Where the two describe the same record differently, that is a finding, not something to reconcile. Rule 04 is one claim, one canonical home; a reader who fetches one path and a reader who fetches the other get different answers about the same fact, and neither can tell the other exists. Silently picking a winner would let a publisher serve two accounts of itself forever and never be told, which is the exact condition a provenance envelope exists to make visible.
flashyos.com is currently in this state: the two documents disagree about when
org/flashyos was asserted and when it expires, and share one edge out of
twenty-one. Which path becomes canonical is a product decision; the audit
reports the disagreement in the meantime, on our own domain, using the tool we
sell.
Referenced nodes
Federation means a repository can emit an edge without owning the node at either
end. gda-group asserts person/michael accountableFor org/gda-capital while the
person record lives elsewhere, and grouping edges under only the nodes a
fragment defines dropped that one silently — 27 served, 26 read, and the one
lost was the accountability edge, so an audit would have reported a Rule 06 gap
that does not exist.
fromFragment now carries such assertions on a node marked referenced: true,
whose subject is empty: we know an identifier and nothing else, and a name we
did not read would be the invention this profile exists to prevent. Its dates
come from the edges it carries — the earliest asserted and the earliest expiry,
so a reference is never more current than the least current thing on it — and
toFragment never emits one back as a definition, so the round trip is exact.
Signatures travel beside the record, not inside it
A directory/0.1 fragment has nowhere to put a countersignature. That is the
separation, not an oversight: the record is one thing and the proof over it is
another. So proof is served alongside, at
/.well-known/directory-signatures.json:
{
"directory": "1",
"source": "repo/gda-group",
"signatures": {
"9f2c…": { "by": "org/beta", "kid": "k1", "jws": "eyJhbGciOiJFZERTQSIs…" }
}
}The key is the seal hash — sha256 of the canonical assertion, the same
fingerprint sealHash() produces and the same one a person quotes in a support
thread. Keying by from/type/to would be shorter and wrong twice over: two
owns edges between the same pair in different instruments would collide, and
an edited fact would keep its stale signature attached and fail verification as
though the signature were forged.
Two properties this buys, both tested:
An orphan is a finding. A signature whose hash matches nothing currently
served lands in orphanedSignatures and the audit says so. "Nobody has signed
this" and "somebody signed something else" are different facts, and only one
of them is about carelessness.
Attaching is not believing. readDirectoryDocument() hangs a signature on
the assertion it names and leaves the edge asserted. Only verifyGraph(),
holding the publisher's keys, may promote it to countersigned — otherwise a
publisher would countersign their own claim by writing a sidecar entry, and the
whole ladder would be decorative.
Where the record comes from
The seed() that read seventeen sibling checkouts is retired. Each repository
now emits the fragment of the world it is the authority for, via
@flashyos/directory, and the fragments merge. Nothing is copied between
repositories, so nothing can drift between them.
What survives from the seed is the part that was never about reading the estate: how a fact gets its date.
Declared beats inferred. Where a source states its own date — updated:
'2026-08-28', or an assertion date on a record — that wins. Somebody who dated
a fact has asserted something git cannot improve on. Where nothing declares one,
the commit that last touched the line carrying the fact is the best available
answer. Per line, not per file: a module holding eleven positions dated all of
them to its last commit would report a fact as fresher than it is.
The two are never blended, and the dating field on every node and edge says
which it was. A fact dated by an edit cannot distinguish "re-checked in August"
from "reformatted in August", so a single figure over both improves every time
somebody runs a formatter, which is not a measurement.
A fact with neither is dropped, not dated to today. Today would be a lie of
exactly the kind expires exists to prevent, and it would be the most
confident-looking fact in the graph.
A number this file got wrong
An earlier revision of this README reported the estate as 70.3% confirmed. That figure was wrong, and how it was wrong is the more useful half.
One reader used a build artefact's generated field as the declared assertion
date for all 59 claims it carried. Every rebuild restamped them as freshly
confirmed, so the metric measured how recently the build had run. The real
figure, once the reader was fixed, is 44% — 154 of 349 public assertions
carry a date somebody checked.
That is the same failure the folio keeps warning about, arriving from the inside: a freshness number that improves when nothing was re-checked is not a freshness number. It is corrected here rather than quietly deleted, because a document that only ever records the flattering measurement is the artefact this package exists to argue against.
Reading it back
resolve takes an identifier and a date and returns the entity with its dates
already applied. The date is a parameter rather than the clock, because the same
graph read on two days is meant to say two different things — and a renderer
that could only ever say "now" would be impossible to test and impossible to
audit.
$ node dist/cli/bin.js resolve claim/the-definition-of-the-metaverse-as-a-category --as-of 2026-08-28
- **status** current — under review by 2028-08-28
The metaverse is the category of persistent, shared virtual spaces …
$ node dist/cli/bin.js resolve claim/the-definition-of-the-metaverse-as-a-category --as-of 2029-01-01
- **status** **unconfirmed since 2028-08-28** — read as at August 2026
The metaverse is the category of persistent, shared virtual spaces … — as at
August 2026, unconfirmed since.Nothing was edited between those two reads. That is the entire point of the expiry field, and it is what a careful reader would have done with the date anyway.
Four renderings, by what the assertion is:
| | What the reader sees | |---|---| | current | The statement, plainly | | degraded | The statement, then "as at November 2021, unconfirmed since" | | review | The statement unchanged, flagged. An equity edge does not stop being true because nobody looked at it | | withheld | Nothing — and the reason. A conflict disclosure past its window is not published at all, and the twin says its absence is not a denial |
Rule 10 renders here too: an edge that crosses an organisational boundary and has not been countersigned reads "— per GDA Capital", never as bare fact. Rule 07 renders as silence: a private entity resolves to exactly what a missing one resolves to, including from the CLI's exit code.
fetch <domain> reads https://<domain>/.well-known/directory.json — how this
reaches an organisation we do not host and never will. There is no registry to
join; discovery is a known path, the way the web has always done it. A document
that does not declare directory/1 is refused rather than read anyway, because
that is how a format acquires dialects nobody agreed to.
Adopting it
npx @flashyos/countersign init yourdomain.comReads what your site already publishes — its schema.org JSON-LD, its
flashyos/1 handshake if it serves one — and writes a directory/1 fragment
you can serve at /.well-known/directory.json. It invents nothing: where the
site says nothing, the field is absent rather than plausible. Every assertion
comes out dated today and marked draft, because you are the one asserting
these facts and you have not checked them yet.
Then run the suite against your own implementation. runConformance takes a
validator and a reader and returns a case-by-case report; the reference
implementation is exported as reference() so you can check the suite behaves
before trusting it about your code.
A document rejected for a different rule than the case names still passes — the suite tests the profile, not our rule numbering.
The audit — what does this site claim, and what can anybody check?
Phase 07, and the front of the product. Point it at any domain. No signup, nothing stored, and it works on a site that has never heard of us.
$ node dist/cli/bin.js audit gda.group
read gda.group · https://gda.group/ · 2 schema.org blocks · /llms.txt
missing /.well-known/directory.json — no dated assertions published
claims 3 relationship claims found on this site
checked 0 of 3 carry a date
0 of 3 say who is asserting them
0 of 3 have been countersigned by the other party
grade unverifiableThe finding is almost always the same, and it is not a telling-off:
schema.org has nowhere to put a date. memberOf, sponsor, funder —
none of them carries when a relationship began, who asserts it, or whether the
other party agrees. A site is not failing to fill in a field; the vocabulary it
is using has no field to fill. Saying that plainly is the difference between a
report that gets closed and one that gets forwarded.
Four grades: none · unverifiable · attributed · countersigned.
Where an audit is allowed to point
audit() takes a domain from a stranger and fetches it from whichever machine
is running. Hosted behind a form — which is what flashyos.com/audit is — that
is server-side request forgery with a text input attached, and the interesting
target is not on the public internet. 169.254.169.254 serves instance
credentials to anything on the box that asks; 10.0.0.0/8 is whatever else is
in the VPC.
So checkHost() refuses every reserved range and reserved TLD by name, before a
socket opens, and auditFetch() is the default fetcher rather than something a
caller has to remember to pass:
audit({ domain: '169.254.169.254', asOf }) // throws HostNotAllowed
audit({ domain: 'example.com', asOf }) // guarded fetcher, 8s timeoutThe half a name check cannot cover is the redirect: an allowed domain answering
302 → http://169.254.169.254/ passes the check and then leaves. auditFetch()
does not follow a redirect that changes host, and follows a same-host one at
most three times — example.com/ → example.com/en/ is the most common shape
on the web, and refusing it would make the audit useless on half its inputs.
checkHost() deliberately does not resolve DNS. A name that resolves inside
the VPC today resolves elsewhere tomorrow, and checking at parse time then
fetching later is a time-of-check/time-of-use gap dressed as a control. The
redirect rule is what actually holds.
engaged — the edge that was missing
Every B2B site has a logo wall, and this vocabulary had nowhere to put one.
cites is for sources, owns is the cap table, and a partner is neither — so
the most common claim in business had no edge type, which is roughly why it has
never been checkable.
engaged requires basis and since, because a relationship claim without
them is a logo. "Partner" is not a fact. "Advised on their 2023 acquisition,
since March 2023" is.
Interfaces are facts too
The Somnium Space disclosure is the incident this profile was built around.
flashy-contracts records another: "four repositories negotiate integration by
reading each other's source… fourteen places where Academy and the core app
reach into each other's internals, and two copies of the session logic that had
already drifted apart before anyone noticed."
Same disease, different substance. So a published OpenAPI specification is a
std/ node with a version, and every repository that names it is a cites
edge carrying the file and line where it does.
std/hunter-identity-v1 v1.1.0 — Flashy Hunter Identity API
std/wallet-v1 v1.0.0 — Flashy Wallet API
prop/claimyour-gold cites std/hunter-identity-v1 source UNPINNED src/app/(portal)/…/skills-card.tsx:5
prop/flashy-sdk cites std/hunter-identity-v1 source UNPINNED src/types.ts:2
prop/flashy-academy cites std/wallet-v1 documentation UNPINNED TECHNICAL_ROADMAP.md:145A consumer citing a version the spec no longer publishes is an error. One that names a spec without pinning anything is a warning — not pinning is the author's call, but an unpinned consumer is one that learns about a breaking change by reading source, which is where this class of incident starts.
Naming is not depending, and the graph does not pretend otherwise. The first
run of this reader reported five consumers, three of which turned out to be one
sentence in a roadmap document. The edge now records context: source or
documentation so the difference is visible.
Signatures — Rule 12
A countersignature is a signature, not a row. Before Phase 05 an edge was
countersigned because our database said so, which meant a reader who fetched
the document from anywhere else had nothing to check.
Now the signature travels in the document, and checking it needs a public JWKS and nothing else — no secret, no account, no call to either signatory:
$ node dist/cli/bin.js verify doc.json --jwks keys.json
keys 2 from keys.json
edges 5 cross an organisational boundary
per org/holder convenes event/summit-2027 — no signature is carried on this assertion
signed org/holder convenes event/signed-summit — signed by org/convener
BROKEN org/holder convenes event/misattributed-summit — signed by org/stranger, but this assertion names org/convener
BROKEN org/holder convenes event/edited-summit — the assertion has changed since it was signed
2 edges claim a countersignature that does not holdThe JWS payload is the canonical assertion itself rather than a hash beside it,
so one check catches a forged signature and an edited fact. state is not
signed — it is the conclusion drawn from whether a signature verifies, and
signing it would break the signature the moment it did its job. visibility
is signed: an ownership edge agreed as private and republished as public fails.
Unsigned, wrongly signed, unknown-key and tampered all render identically, as one organisation's claim. "I could not check" is not "checked and fine".
Countersigning — rung two, end to end
Rule 12 says what a countersignature is. This is how one gets made.
# 1. The asserting org asks. Prints the sentence the counterparty will read,
# so whoever is about to send it can check what they are asking for.
flashy-directory invite directory.json \
--edge org/gda-capital engaged org/metaverse-group \
--as "GDA Capital" --their "Metaverse Group" \
--source https://gda.group/.well-known/directory.json
# 2. The counterparty opens the link, reads the claim, and signs — in their own
# browser, with a key that never leaves it.
# 3. The asserting org applies what came back. Verified before anything is
# written; a bad signature writes nothing and exits 1.
flashy-directory countersign directory.json \
--edge org/gda-capital engaged org/metaverse-group \
--signature signature.json --jwks https://metaverse.group/.well-known/directory-keys.json
# 4. A stranger checks it, holding only the two public documents.
flashy-directory verify directory.json --jwks https://metaverse.group/.well-known/directory-keys.jsonThe invitation is not a credential. It grants nothing and losing it costs nothing, which is the whole reason this is a link rather than an account:
- Holding it does not let you sign. A countersignature verifies only against the signer's own published key, and the private half never leaves their browser.
- Editing it does not let you forge. A tampered link yields a signature over the tampered claim, which matches nothing the asserting org holds.
- It has no timer to enforce. The assertion inside carries its own
expires.
signingInput() in invite.ts is the single definition of what a
countersignature covers. signAssertion() on a server and the signing page in a
browser both read it, so a signature made in one verifies in the other —
invite.test.ts pins the two together byte for byte, and
countersign.e2e.test.ts runs the four commands above against real WebCrypto
with nothing stubbed.
Signing happens in the browser because it has to. A service holding the counterparty's private key and signing "on their behalf" has not built a countersignature; it has built a database row with extra steps, and the whole argument for the signature was that it proves something a row cannot.
The countersigned share
import { standing, say } from '@flashyos/countersign';
say(standing(graph));
// "0 of 19 public claims about another organisation have been countersigned by
// that organisation. None yet — this is the number the next phase exists to move."The one number the project is judged on, published from the day it is zero. A metric that only appears once it looks good is indistinguishable from one chosen once it looked good.
Only public cross-boundary edges count. operates and declares are statements
an organisation makes about itself and nobody is being asked to agree with them
— counting those would let a firm improve its score by describing its own
websites in more detail, which is the failure this profile exists to remove.
byOrg breaks it down per asserter, so a good estate-wide average cannot hide
one property that has signed nothing.
The badge
The one artefact that lands on somebody else's website, which makes every constraint on it a hard one.
import { badge } from '@flashyos/countersign';
badge({ edge, names: { asserter: 'Meridian Capital', counterparty: 'Northwind Systems' },
verifyUrl: 'https://flashyos.com/verify/…', asOf: '2027-03-01' });
// → { state: 'countersigned', aged: false,
// text: 'Countersigned by Northwind Systems, 14 Feb 2027',
// html: '<a id="cs-countersigned-…" …>' }- No script, no webfont, no external stylesheet, no network call. A font pulled onto a host's page is a cost they did not agree to, and the reason performance teams strip embeds. Inline SVG mark, system font stack.
- Always a date, always a name. "Verified" alone is a claim about nothing and a competitor could paste it into their HTML in ten seconds.
- It can become unflattering on its own. Past the assertion's own expiry it reads "as at" and dims. A badge that cannot age is the thing this profile exists to prevent; shipping one would contradict the product on every site that embedded it.
- Everything is escaped. This is markup a stranger pastes into their page — an unescaped organisation name is a cross-site scripting hole in a third party's site with our name on it.
asOfis an argument, never a clock. Rule 13, so the same assertion renders identically in a test, in a build, and on a date that has not happened.
theme: 'auto' is the only mode that emits a <style> element — a media query
cannot live in an inline style — and it is scoped to the badge's own generated
id so it cannot reach anything else on the host's page.
Key rotation
A JWK may carry retired, the day it stopped being used for new signatures.
Keys are retired in place and stay published, because a JWKS that simply
drops a rotated key turns every signature it ever made into unknown-key —
which reads to a stranger as forgery, so the organisation doing the responsible
thing would look like the one doing the worst thing.
checkSignature then returns retired-key: not valid, not a failure.
retired-key — signed by org/northwind with a key retired on 2026-06-01;
nothing here records when the signature was made, so it cannot
be placed before or after that dateThat last clause is the honest limit and the reason the transparency log is on the roadmap. A signature proves who agreed and what they agreed to; it says nothing about when, so a retired key cannot be scoped to "everything before the breach". Until timestamps land, the verdict says so rather than guessing.
verifyGraph keeps a retired-key edge countersigned. The signature is
real and from the named party; demoting it would mean any organisation that
rotates a key silently loses every countersignature it ever gave, and the
caveat belongs in the check a reader sees rather than in a state change that
erases the fact.
parseJwks refuses a set publishing two keys under one kid — otherwise every
verdict is a coin toss decided by array order.
What is not built yet
The MCP tool. Phase 02 calls for one so an agent that has never read our
documentation can still read the graph. @flashyos/mcp is not where it goes:
every tool there is pinned by a drift test to exactly one AgentReporter
method, in both directions, and a Directory read wraps no reporter method at
all. Forcing it in would break a good invariant to save a package. It belongs in
its own server alongside this one, and it is worth doing deliberately rather
than as a footnote — recorded here so the gap is a decision rather than an
oversight.
The rules, as checks
validate() enforces the specification and names the rule in every finding,
because a validator that reports "invalid" teaches nobody anything. It reports
every problem at once, never just the first.
| Rule | Check | |---|---| | 03 | Nothing expires before it was asserted | | 04 | One claim, one canonical home | | 05 | Identifiers well formed, unique, and matching their kind's prefix | | 06 | Every organisation has an accountable human (warning) | | 07 | Nothing in the private tier is marked public | | 09 | A stale conflict disclosure fails the build | | 10 | No cross-boundary edge claims a countersignature it does not have |
conformance() is separate: it checks the graph against the world rather than
against itself, and fails if a charter in the manifest is missing from the graph.
Determinism
Sources are read in manifest order, ids sort, edges sort, and nothing reads the
clock except the caller's --as-of. Two runs on the same inputs are
byte-identical, which is what makes directory.json reviewable in a diff rather
than merely regenerable.
Tests
npx vitest runThe estate suite runs only where the other repositories are checked out as siblings — the usual local setup, and not the case on a runner that has cloned only this one. The skip is stated rather than silent: a conformance suite that quietly passes when it checked nothing is worse than one that fails.
Licence
Apache-2.0. The schema is published under it at Phase 04 with a conformance suite — a registry one company controls is a database; a registry anyone can implement is a standard.
Why this is not @flashyos/directory
Two implementations of the Directory were built in parallel. They descend from
the same folio — identical kinds, identical kind/slug prefixes, a shared list
of edge types — and they solved different halves of it.
@flashyos/directory federates: each repository emits the fragment of the world
it is the authority for, and merge() combines them. That is the right
distribution model, and it is right for a reason worth quoting from its own
source: "a script that reaches into seventeen checkouts works on one laptop and
fails on every runner." This package's earlier seed() was exactly that script,
which is why its estate suite skipped in CI. It has been retired rather than
defended.
This package is the half the record layer has no opinion about: proof. A countersignature from the party a claim is about, an audit of what a website can currently demonstrate, a badge that carries a date and can age, and the rule that a signature between related parties is not evidence.
The split is not a Rule 04 violation, it is Rule 04 applied. One claim, one
canonical home: the record has one home, the signature over it has another.
The vocabulary — kinds, prefixes, edge types — is imported from the directory
package and never restated, so there is one definition of what a relationship
can be. fromFragment and toFragment in model.ts are the only place the two
shapes meet, and toFragment is deliberately lossy: a countersignature does not
survive it, because directory/0.1 has nowhere to put one. That is stated
rather than hidden, because a silent conversion that drops proof would be the
same mistake schema.org makes with a date.
What closes the gap is not a wider record format but a second document.
sidecarFor() emits the signatures a fragment cannot carry, document.ts
puts them back, and the pair round-trips losslessly — the record stays the
record, and the proof stays proof over it.
