// LabelBase — the label-ops intent layer of a co-authored (multi-mod) labeler.
//
// Same reskin as RepoBase (repo-base.js): N attested MODERATORS are EQUAL writers of a shared
// StateBase; each appends label INTENTS (label / negate a subject) into the linearized KV view; a
// lease-elected signer materializes that view into ONE signed com.atproto.label FEED under the
// labeler's single #atproto_label key (see lib/label-signer.js — later slice). This is the atproto
// ozone shape (many mods → one labeler DID/key) built on the swarm-PDS machinery: authority is
// POSITIONAL (the op's autobase writer = the mod), the world sees ONE labeler identity.
//
// This file owns ONLY the label-ops convention over the generic KV intent-log. Membership/admission
// (who is a mod, role gating, ban) is StateBase's job (attestation + addWriter/removeWriter, exactly
// as for device writers); the lease (lease.js) and signing (label-signer.js) are separate modules.
//
// Substrate INJECTED (StateBaseV3 or any KV log with async set/del/get/list) — runtime-agnostic and
// unit-testable without Bare/StateBase, exactly like RepoBase. KV mapping: a label targets
// (uri, val); key = "label\x00<uri>\x00<val>", value = { uri, val, neg, cts }. The `label\x00` prefix
// keeps intents out of reserved control keys (__lease__/__members__), which StateBase never surfaces
// here. LWW on the key; retraction is NEGATION (neg:true), not deletion (atproto semantics).
const SEP = '\x00'
const NS = 'label'
export class LabelBase {
// statebase: an async KV intent-log (StateBaseV3 or compatible). src: the labeler's DID — the ONE
// identity every emitted label carries. Mods are WRITERS, not the src (authority is positional).
//
// ns: the intent key prefix. ⛔ On a REPO's own StateBase (hpp hosts the labeler on the DID's base) pass a
// reserved "__" name: RepoBase lists every key holding a "/" under a non-"__" collection as a record, and
// an intent on a post URI has a "/" — under the default "label" it would be signed into the repo.
constructor (statebase, { src, ns = NS } = {}) {
if (!statebase || typeof statebase.set !== 'function' || typeof statebase.list !== 'function') {
throw new Error('LabelBase: a statebase with async set/del/get/list is required')
}
if (!src) throw new Error('LabelBase: src (the labeler DID) required')
this.sb = statebase
this.src = src
this.ns = ns
}
_key (uri, val) { return this.ns + SEP + uri + SEP + val }
// Append a label intent: label (or, with neg:true, retract) subject `uri` with value `val`. cts is
// caller-supplied for determinism (else now). Authority to WRITE is StateBase admission; role gating
// (mod vs member) is a membership concern (later slice) — this layer just records the intent.
async label (uri, val, { neg = false, cts } = {}) {
if (!uri || !val) throw new Error('LabelBase.label: uri + val required')
const intent = { uri, val, neg: !!neg, cts: cts || new Date().toISOString() }
await this.sb.set(this._key(uri, val), intent)
return intent
}
negate (uri, val, opts = {}) { return this.label(uri, val, { ...opts, neg: true }) }
// Bring (uri, val) to a state, writing only on a CHANGE — what a projector re-asserting a whole label
// set on every pass needs. An unchanged state keeps its cts: a new cts is a new signed label in the
// content-keyed __label__ log, so re-writing it would grow the log on every pass. Negating a label that
// was never applied writes nothing either (there is nothing to retract). → { intent, changed }.
async ensure (uri, val, { neg = false, cts } = {}) {
if (!uri || !val) throw new Error('LabelBase.ensure: uri + val required')
const cur = await this.sb.get(this._key(uri, val))
if (cur ? !!cur.neg === !!neg : neg) return { intent: cur || null, changed: false }
return { intent: await this.label(uri, val, { neg, cts }), changed: true }
}
// All label intents in the linearized view, INCLUDING negated ones — what the signer materializes
// (it emits neg labels too, so consumers see retractions). [{ uri, val, neg, cts }].
async intents () {
const map = await this.sb.list(this.ns + SEP)
return Object.keys(map).map((k) => map[k]).filter(Boolean)
}
// Currently-ACTIVE (non-negated) labels. [{ uri, val, cts }].
async active () {
return (await this.intents()).filter((i) => !i.neg).map((i) => ({ uri: i.uri, val: i.val, cts: i.cts }))
}
// Active labels on one subject uri.
async labelsFor (uri) {
return (await this.active()).filter((i) => i.uri === uri)
}
}
export { NS as LABEL_NS }