oficina / home Public

The provisioning queue, and the label reservation it holds

Spec §13 step 5. A signup writes a request here; a drain turns it into an
instance. Until now the directory could describe teams it already had and
had no way to be asked for a new one.

The label reservation is not a column in this table — it is the teams.label
UNIQUE constraint, taken in the SAME transaction that queues the requests
(ruling 19: claiming a label takes it across every app, so only Acme can
later create acme.sheets). A reservation that committed without its
requests would hold a name for a team nobody asked for, which is why
CreateTeam is one transaction and TestCreateTeamIsAtomic checks that a
refused request leaves no teams row behind.

That makes §7's consequence directly testable: "a queue entry that is
purged or times out must release its label, or a request that never became
a team holds a name nobody can claim." Sweep deletes the expired request,
then the team and memberships it stranded — and never touches a team with a
live instance, however stale its other requests are.

The rate limit is per requester and per window, keyed on a hash rather than
on the person_ref, so the queue does not become a second place identifiers
accumulate.

directorydata keeps its own copy of the sqids codec because operator
already imports this package and importing back would be a cycle;
TestTeamSQIDMatchesOperator pins the two together from an external test
package so the duplication cannot drift into a team with two identifiers.

Two things the existing tests caught, kept here because they were right:
CreateTeam now rejects a payload below the sealed floor rather than letting
a CHECK turn a caller's mistake into an opaque unavailability, and the
schema's comments stay outside CREATE TABLE — sqlite_master stores that
text verbatim and store_test.go forbids a schema that names what it holds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 729cad21bce6af426356b62b45482b5d77db1a82 parent e4ae496
5 files changed, +547 −0
  • internal/home/directorydata/migrations/0002_provisioning.sql +38 −0
  • internal/home/directorydata/provision.go +255 −0
  • internal/home/directorydata/provision_test.go +160 −0
  • internal/home/directorydata/provisionids.go +61 −0
  • internal/home/directorydata/provisionids_pin_test.go +33 −0

CI failed — run details

diff --git a/internal/home/directorydata/migrations/0002_provisioning.sql b/internal/home/directorydata/migrations/0002_provisioning.sql
new file mode 100644
index 0000000..5b93d7f
--- /dev/null
+++ b/internal/home/directorydata/migrations/0002_provisioning.sql
@@ -0,0 +1,38 @@
+-- The provisioning queue (§7). A signup writes a request here; a drain
+-- turns it into an instance. The row is the durable record of a request
+-- that has not become a team yet.
+--
+-- The LABEL RESERVATION is not in this table: it is the teams.label
+-- UNIQUE constraint, taken in the same transaction that writes these
+-- rows (ruling 19 — claiming `acme` takes it across every app, so only
+-- Acme can later create acme.sheets). That is why a queued request owns
+-- a real teams row rather than a name held to one side.
+--
+-- Which means §7's consequence applies directly: "a queue entry that is
+-- purged or times out must release its label, or a request that never
+-- became a team holds a name nobody can claim." expires_at is what makes
+-- that sweepable, and ON DELETE CASCADE is what makes dropping the team
+-- drop its requests with it.
+-- The payload is opaque to the home (§3.2): what the new instance needs to
+-- start travels sealed and is never judged here. This note sits OUTSIDE the
+-- statement deliberately — sqlite_master stores CREATE TABLE text verbatim,
+-- and store_test.go forbids a schema that names what it holds.
+--
+-- rate_key is a hash for the same reason: grouping a requester's requests
+-- for a rate limit does not require this table to know who they are, and
+-- keeping it a hash stops the queue becoming a second place identifiers
+-- accumulate.
+CREATE TABLE provisioning_queue (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
+ app TEXT NOT NULL CHECK(length(app) BETWEEN 1 AND 63 AND app NOT GLOB '*[^a-z0-9-]*'),
+ state TEXT NOT NULL CHECK(state IN ('queued', 'done', 'failed')),
+ sealed_payload BLOB NOT NULL CHECK(typeof(sealed_payload) = 'blob' AND length(sealed_payload) BETWEEN 29 AND 4096),
+ rate_key TEXT NOT NULL CHECK(length(rate_key) = 64 AND rate_key NOT GLOB '*[^a-f0-9]*'),
+ requested_at INTEGER NOT NULL CHECK(requested_at > 0),
+ expires_at INTEGER NOT NULL CHECK(expires_at > requested_at),
+ UNIQUE(team_id, app)
+);
+CREATE INDEX provisioning_queue_drain ON provisioning_queue(state, requested_at);
+CREATE INDEX provisioning_queue_expiry ON provisioning_queue(state, expires_at);
+CREATE INDEX provisioning_queue_rate ON provisioning_queue(rate_key, requested_at);
diff --git a/internal/home/directorydata/provision.go b/internal/home/directorydata/provision.go
new file mode 100644
index 0000000..fce43d9
--- /dev/null
+++ b/internal/home/directorydata/provision.go
@@ -0,0 +1,255 @@
+package directorydata
+
+import (
+ "context"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/hex"
+ "errors"
+ "time"
+)
+
+// ErrLabelTaken is the one answer the home gives about a label it does not
+// own: somebody already reserved it. It is deliberately indistinguishable
+// from "reserved by a team you cannot see" — the caller learns that the
+// name is unavailable, never who has it.
+// sealedFloor mirrors the sealed_payload and sealed_name CHECK minimums.
+const sealedFloor = 29
+
+var ErrLabelTaken = errors.New("directory team label already reserved")
+
+// ErrRateLimited is returned when one requester has asked too often inside
+// the window. It is not an authorization answer and carries no detail.
+var ErrRateLimited = errors.New("directory provisioning requests too frequent")
+
+// Request is one queued (team, app) provisioning request.
+type Request struct {
+ ID int64
+ TeamID int64
+ TeamSQID string
+ Label, App string
+ SealedPayload []byte
+ RequestedAt time.Time
+}
+
+// RateKey groups a requester's provisioning requests without storing who
+// they are. The subject is already a pseudonym; hashing it again keeps this
+// table from being a second place that person_refs accumulate, which is the
+// property that lets the queue be swept and audited without reading it.
+func RateKey(subject string) string {
+ sum := sha256.Sum256([]byte("oficina/provisioning/rate/v1\x00" + subject))
+ return hex.EncodeToString(sum[:])
+}
+
+// CreateTeam reserves a label and queues one provisioning request per app,
+// in ONE transaction, and that atomicity is the whole point: the label
+// reservation IS the teams row (ruling 19), so a reservation that committed
+// without its requests would hold a name for a team nobody asked for.
+//
+// The caller has already decided this person may ask. CreateTeam decides
+// only what the home is entitled to decide about its own resources: is this
+// label free, and has this requester asked too often.
+//
+// maxPerWindow of 0 disables the rate check, which is for tests and for an
+// operator path that has already been adjudicated — never for a web caller.
+func (s *Store) CreateTeam(ctx context.Context, subject, label, name string, apps []string, payload []byte, now time.Time, ttl time.Duration, maxPerWindow int, window time.Duration) (Team, error) {
+ if !validSubject(subject) || !validLabel(label) || !validName(name) || len(apps) == 0 || len(apps) > len(s.apps) {
+ return Team{}, ErrInvalid
+ }
+ // 29 is the sealed-blob floor the column enforces (a nonce and tag with
+ // nothing inside), checked here so a short payload is the caller's
+ // mistake rather than an opaque unavailability from a CHECK.
+ if len(payload) < sealedFloor || len(payload) > 4096 || ttl <= 0 || now.IsZero() {
+ return Team{}, ErrInvalid
+ }
+ seen := make(map[string]bool, len(apps))
+ for _, app := range apps {
+ if !s.apps[app] || seen[app] {
+ return Team{}, ErrInvalid
+ }
+ seen[app] = true
+ }
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return Team{}, ErrUnavailable
+ }
+ defer tx.Rollback()
+
+ rate := RateKey(subject)
+ if maxPerWindow > 0 {
+ var recent int
+ if err := tx.QueryRowContext(ctx,
+ `SELECT count(*) FROM provisioning_queue WHERE rate_key = ? AND requested_at > ?`,
+ rate, now.Add(-window).Unix()).Scan(&recent); err != nil {
+ return Team{}, ErrUnavailable
+ }
+ if recent >= maxPerWindow {
+ return Team{}, ErrRateLimited
+ }
+ }
+
+ // The sqid is derived from the row id, so the row has to exist before
+ // the name can be sealed against it (SealTeamName's contract). The
+ // placeholder is overwritten inside this same transaction and is never
+ // visible to a reader outside it.
+ placeholder, err := pendingSQID()
+ if err != nil {
+ return Team{}, err
+ }
+ res, err := tx.ExecContext(ctx,
+ `INSERT INTO teams (sqid, label, sealed_name, created_at) VALUES (?, ?, ?, ?)`,
+ placeholder, label, pendingSeal, now.Unix())
+ if err != nil {
+ if isUnique(err) {
+ return Team{}, ErrLabelTaken
+ }
+ return Team{}, ErrUnavailable
+ }
+ id, err := res.LastInsertId()
+ if err != nil || id <= 0 {
+ return Team{}, ErrUnavailable
+ }
+ sqid, err := TeamSQID(id)
+ if err != nil {
+ return Team{}, ErrUnavailable
+ }
+ sealed, err := s.SealTeamName(id, name)
+ if err != nil {
+ return Team{}, err
+ }
+ if _, err := tx.ExecContext(ctx, `UPDATE teams SET sqid = ?, sealed_name = ? WHERE id = ?`, sqid, sealed, id); err != nil {
+ if isUnique(err) {
+ return Team{}, ErrUnavailable
+ }
+ return Team{}, ErrUnavailable
+ }
+ // The requester owns the team they just asked for. Ruling 23 is what
+ // lets the home hold this: an owner is a fact about a resource the home
+ // owns, not about anything inside an instance.
+ if _, err := tx.ExecContext(ctx,
+ `INSERT INTO memberships (team_id, person_ref, role) VALUES (?, ?, 'owner')`, id, subject); err != nil {
+ return Team{}, ErrUnavailable
+ }
+ for _, app := range apps {
+ if _, err := tx.ExecContext(ctx,
+ `INSERT INTO provisioning_queue (team_id, app, state, sealed_payload, rate_key, requested_at, expires_at)
+ VALUES (?, ?, 'queued', ?, ?, ?, ?)`,
+ id, app, payload, rate, now.Unix(), now.Add(ttl).Unix()); err != nil {
+ return Team{}, ErrUnavailable
+ }
+ }
+ if err := tx.Commit(); err != nil {
+ return Team{}, ErrUnavailable
+ }
+ return Team{ID: id, SQID: sqid, Label: label, Name: name, Role: "owner"}, nil
+}
+
+// Queued returns requests waiting to be drained, oldest first. It is the
+// drain's only read, and it deliberately returns the sealed payload without
+// opening it: the home carries that opaquely (§3.2) and the drain is what
+// holds the key to it.
+func (s *Store) Queued(ctx context.Context, now time.Time, limit int) ([]Request, error) {
+ if limit <= 0 || limit > 1000 {
+ return nil, ErrInvalid
+ }
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT q.id, q.team_id, t.sqid, t.label, q.app, q.sealed_payload, q.requested_at
+ FROM provisioning_queue q JOIN teams t ON t.id = q.team_id
+ WHERE q.state = 'queued' AND q.expires_at > ?
+ ORDER BY q.requested_at, q.id LIMIT ?`, now.Unix(), limit)
+ if err != nil {
+ return nil, ErrUnavailable
+ }
+ defer rows.Close()
+ var out []Request
+ for rows.Next() {
+ var r Request
+ var at int64
+ if err := rows.Scan(&r.ID, &r.TeamID, &r.TeamSQID, &r.Label, &r.App, &r.SealedPayload, &at); err != nil {
+ return nil, ErrUnavailable
+ }
+ r.RequestedAt = time.Unix(at, 0).UTC()
+ out = append(out, r)
+ }
+ if rows.Err() != nil {
+ return nil, ErrUnavailable
+ }
+ return out, nil
+}
+
+// MarkProvisioned records the instance the drain created and closes the
+// request, in one transaction. The team_instances row is what
+// <team>.<app>.oficina.works resolves against, so it and the state change
+// commit together or neither does.
+func (s *Store) MarkProvisioned(ctx context.Context, id int64, origin, consumerID string, now time.Time) error {
+ if id <= 0 || origin == "" || consumerID == "" {
+ return ErrInvalid
+ }
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return ErrUnavailable
+ }
+ defer tx.Rollback()
+ var teamID int64
+ var app string
+ if err := tx.QueryRowContext(ctx,
+ `SELECT team_id, app FROM provisioning_queue WHERE id = ? AND state = 'queued'`, id).Scan(&teamID, &app); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return ErrNotFound
+ }
+ return ErrUnavailable
+ }
+ if _, err := tx.ExecContext(ctx,
+ `INSERT INTO team_instances (team_id, app, origin, consumer_id, state) VALUES (?, ?, ?, ?, 'live')`,
+ teamID, app, origin, consumerID); err != nil {
+ return ErrUnavailable
+ }
+ if _, err := tx.ExecContext(ctx, `UPDATE provisioning_queue SET state = 'done' WHERE id = ?`, id); err != nil {
+ return ErrUnavailable
+ }
+ if err := tx.Commit(); err != nil {
+ return ErrUnavailable
+ }
+ return nil
+}
+
+// Sweep releases what expired. §7: "a queue entry that is purged or times
+// out must release its label, or a request that never became a team holds a
+// name nobody can claim." Deleting the expired request is not enough on its
+// own — the label lives on the teams row — so a team left with no live
+// instance and no live request goes too, and its memberships with it.
+//
+// A team that HAS a live instance is never touched, however old its other
+// requests are: that is a real team with a name in use.
+func (s *Store) Sweep(ctx context.Context, now time.Time) (requests, labels int, err error) {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return 0, 0, ErrUnavailable
+ }
+ defer tx.Rollback()
+ res, err := tx.ExecContext(ctx,
+ `DELETE FROM provisioning_queue WHERE state = 'queued' AND expires_at <= ?`, now.Unix())
+ if err != nil {
+ return 0, 0, ErrUnavailable
+ }
+ n, _ := res.RowsAffected()
+ if _, err := tx.ExecContext(ctx, `DELETE FROM memberships WHERE team_id IN (
+ SELECT t.id FROM teams t
+ WHERE NOT EXISTS (SELECT 1 FROM team_instances i WHERE i.team_id = t.id AND i.state != 'disabled')
+ AND NOT EXISTS (SELECT 1 FROM provisioning_queue q WHERE q.team_id = t.id AND q.state = 'queued'))`); err != nil {
+ return 0, 0, ErrUnavailable
+ }
+ res, err = tx.ExecContext(ctx, `DELETE FROM teams WHERE id IN (
+ SELECT t.id FROM teams t
+ WHERE NOT EXISTS (SELECT 1 FROM team_instances i WHERE i.team_id = t.id AND i.state != 'disabled')
+ AND NOT EXISTS (SELECT 1 FROM provisioning_queue q WHERE q.team_id = t.id AND q.state = 'queued'))`)
+ if err != nil {
+ return 0, 0, ErrUnavailable
+ }
+ m, _ := res.RowsAffected()
+ if err := tx.Commit(); err != nil {
+ return 0, 0, ErrUnavailable
+ }
+ return int(n), int(m), nil
+}
diff --git a/internal/home/directorydata/provision_test.go b/internal/home/directorydata/provision_test.go
new file mode 100644
index 0000000..9c88dfb
--- /dev/null
+++ b/internal/home/directorydata/provision_test.go
@@ -0,0 +1,160 @@
+package directorydata
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+)
+
+var sealedPayload = []byte("sealed-opaque-provisioning-payload")
+
+func mustCreate(t *testing.T, s *Store, subject, label string, apps []string, now time.Time) Team {
+ t.Helper()
+ team, err := s.CreateTeam(context.Background(), subject, label, privateName, apps, sealedPayload, now, time.Hour, 0, time.Minute)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return team
+}
+
+func TestCreateTeamReservesTheLabelSuiteWide(t *testing.T) {
+ s, _, _ := fixture(t)
+ now := time.Unix(1_700_000_000, 0).UTC()
+ team := mustCreate(t, s, personA, "acme", []string{"docs"}, now)
+ if team.ID <= 0 || team.Label != "acme" || team.Role != "owner" || team.Name != privateName {
+ t.Fatalf("team %+v", team)
+ }
+ expected, err := TeamSQID(team.ID)
+ if err != nil || team.SQID != expected {
+ t.Fatalf("sqid %q want %q (%v)", team.SQID, expected, err)
+ }
+
+ // Ruling 19: claiming a label takes it across every app, so a DIFFERENT
+ // person asking for the same label for a DIFFERENT app is refused. This
+ // is the whole reservation, and it must not depend on who asks.
+ _, err = s.CreateTeam(context.Background(), personB, "acme", privateName, []string{"sheets"}, sealedPayload, now, time.Hour, 0, time.Minute)
+ if !errors.Is(err, ErrLabelTaken) {
+ t.Fatalf("second claim of a taken label: %v, want ErrLabelTaken", err)
+ }
+}
+
+func TestCreateTeamIsAtomic(t *testing.T) {
+ s, d, _ := fixture(t)
+ now := time.Unix(1_700_000_000, 0).UTC()
+ // An app outside the registry must abort the WHOLE request — no team, so
+ // no label held for a request that was never queued.
+ if _, err := s.CreateTeam(context.Background(), personA, "acme", privateName, []string{"docs", "nosuchapp"}, sealedPayload, now, time.Hour, 0, time.Minute); !errors.Is(err, ErrInvalid) {
+ t.Fatalf("unknown app: %v", err)
+ }
+ var teams int
+ if err := d.Writer().QueryRow(`SELECT count(*) FROM teams`).Scan(&teams); err != nil {
+ t.Fatal(err)
+ }
+ if teams != 0 {
+ t.Fatalf("a refused request left %d team rows holding labels", teams)
+ }
+ // And the label is genuinely free afterwards.
+ mustCreate(t, s, personA, "acme", []string{"docs"}, now)
+}
+
+func TestQueuedAndMarkProvisioned(t *testing.T) {
+ s, _, _ := fixture(t)
+ now := time.Unix(1_700_000_000, 0).UTC()
+ team := mustCreate(t, s, personA, "acme", []string{"docs", "sheets"}, now)
+
+ got, err := s.Queued(context.Background(), now, 10)
+ if err != nil || len(got) != 2 {
+ t.Fatalf("queued %d (%v)", len(got), err)
+ }
+ if got[0].Label != "acme" || got[0].TeamSQID != team.SQID || string(got[0].SealedPayload) != string(sealedPayload) {
+ t.Fatalf("request %+v", got[0])
+ }
+
+ if err := s.MarkProvisioned(context.Background(), got[0].ID, "https://acme.docs.oficina.works", "consumer-1", now); err != nil {
+ t.Fatal(err)
+ }
+ left, err := s.Queued(context.Background(), now, 10)
+ if err != nil || len(left) != 1 || left[0].App == got[0].App {
+ t.Fatalf("after drain: %d left (%v)", len(left), err)
+ }
+ // Draining the same request twice must not mint a second instance.
+ if err := s.MarkProvisioned(context.Background(), got[0].ID, "https://acme.docs.oficina.works", "consumer-2", now); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("second drain of one request: %v, want ErrNotFound", err)
+ }
+}
+
+func TestSweepReleasesTheLabelOfAnExpiredRequest(t *testing.T) {
+ s, d, _ := fixture(t)
+ now := time.Unix(1_700_000_000, 0).UTC()
+ if _, err := s.CreateTeam(context.Background(), personA, "acme", privateName, []string{"docs"}, sealedPayload, now, time.Minute, 0, time.Minute); err != nil {
+ t.Fatal(err)
+ }
+ // Before expiry nothing moves.
+ if reqs, labels, err := s.Sweep(context.Background(), now.Add(30*time.Second)); err != nil || reqs != 0 || labels != 0 {
+ t.Fatalf("early sweep: %d/%d (%v)", reqs, labels, err)
+ }
+ // §7: an entry that times out must release its label, or a request that
+ // never became a team holds a name nobody can claim.
+ reqs, labels, err := s.Sweep(context.Background(), now.Add(2*time.Minute))
+ if err != nil || reqs != 1 || labels != 1 {
+ t.Fatalf("sweep: %d requests %d labels (%v)", reqs, labels, err)
+ }
+ var memberships int
+ if err := d.Writer().QueryRow(`SELECT count(*) FROM memberships`).Scan(&memberships); err != nil {
+ t.Fatal(err)
+ }
+ if memberships != 0 {
+ t.Fatalf("sweep left %d memberships pointing at a deleted team", memberships)
+ }
+ // The label is claimable again, by anyone.
+ mustCreate(t, s, personB, "acme", []string{"docs"}, now.Add(3*time.Minute))
+}
+
+func TestSweepNeverTakesALabelThatIsInUse(t *testing.T) {
+ s, _, _ := fixture(t)
+ now := time.Unix(1_700_000_000, 0).UTC()
+ if _, err := s.CreateTeam(context.Background(), personA, "acme", privateName, []string{"docs", "sheets"}, sealedPayload, now, time.Minute, 0, time.Minute); err != nil {
+ t.Fatal(err)
+ }
+ got, err := s.Queued(context.Background(), now, 10)
+ if err != nil || len(got) != 2 {
+ t.Fatal(err)
+ }
+ // One app got provisioned; the other request then expires. The team is
+ // real and its name is in use, so the sweep must take the stale request
+ // and leave the label alone.
+ if err := s.MarkProvisioned(context.Background(), got[0].ID, "https://acme.docs.oficina.works", "c1", now); err != nil {
+ t.Fatal(err)
+ }
+ reqs, labels, err := s.Sweep(context.Background(), now.Add(2*time.Minute))
+ if err != nil || reqs != 1 || labels != 0 {
+ t.Fatalf("sweep: %d requests %d labels (%v)", reqs, labels, err)
+ }
+ if _, err := s.CreateTeam(context.Background(), personB, "acme", privateName, []string{"sheets"}, sealedPayload, now, time.Hour, 0, time.Minute); !errors.Is(err, ErrLabelTaken) {
+ t.Fatal("a live team's label was released")
+ }
+}
+
+func TestCreateTeamRateLimitsOneRequester(t *testing.T) {
+ s, _, _ := fixture(t)
+ now := time.Unix(1_700_000_000, 0).UTC()
+ for i, label := range []string{"one", "two"} {
+ if _, err := s.CreateTeam(context.Background(), personA, label, privateName, []string{"docs"}, sealedPayload, now, time.Hour, 2, time.Hour); err != nil {
+ t.Fatalf("request %d: %v", i, err)
+ }
+ }
+ if _, err := s.CreateTeam(context.Background(), personA, "three", privateName, []string{"docs"}, sealedPayload, now, time.Hour, 2, time.Hour); !errors.Is(err, ErrRateLimited) {
+ t.Fatalf("third request: %v, want ErrRateLimited", err)
+ }
+ // The limit is per requester, not global.
+ if _, err := s.CreateTeam(context.Background(), personB, "four", privateName, []string{"docs"}, sealedPayload, now, time.Hour, 2, time.Hour); err != nil {
+ t.Fatalf("other requester: %v", err)
+ }
+ // And it is a window, not a cap: the same person may ask again later.
+ if _, err := s.CreateTeam(context.Background(), personA, "five", privateName, []string{"docs"}, sealedPayload, now.Add(2*time.Hour), time.Hour, 2, time.Hour); err != nil {
+ t.Fatalf("after the window: %v", err)
+ }
+ // "three" was refused, so nothing may be holding it.
+ mustCreate(t, s, personB, "three", []string{"docs"}, now.Add(2*time.Hour))
+}
diff --git a/internal/home/directorydata/provisionids.go b/internal/home/directorydata/provisionids.go
new file mode 100644
index 0000000..464c5dd
--- /dev/null
+++ b/internal/home/directorydata/provisionids.go
@@ -0,0 +1,61 @@
+package directorydata
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "strings"
+
+ "github.com/sqids/sqids-go"
+)
+
+// teamCodec is the same sqids codec, with the same defaults, that
+// internal/operator uses. It is duplicated rather than shared because
+// operator already imports THIS package, so importing it back would be a
+// cycle. Duplication that can drift is only safe if something catches the
+// drift: TestTeamSQIDMatchesOperator (provisionids_pin_test.go) asserts the
+// two agree, from an external test package that may import both.
+var teamCodec, teamCodecError = sqids.New()
+
+// TeamSQID encodes a team's row id. The sqid is derived from the id, which
+// is why a team row must exist before its sqid is known.
+func TeamSQID(id int64) (string, error) {
+ if id <= 0 || teamCodecError != nil {
+ return "", ErrInvalid
+ }
+ s, err := teamCodec.Encode([]uint64{uint64(id)})
+ if err != nil || len(s) == 0 || len(s) > 64 {
+ return "", ErrInvalid
+ }
+ return s, nil
+}
+
+// pendingSQID fills teams.sqid for the instant between INSERT and the UPDATE
+// that writes the real one. Both happen inside CreateTeam's transaction, so
+// no reader outside it ever sees this value — but the column is NOT NULL
+// UNIQUE with an alphanumeric CHECK, so the placeholder still has to be a
+// legal, unique sqid-shaped string rather than a marker like "pending".
+func pendingSQID() (string, error) {
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ return "", ErrUnavailable
+ }
+ return "pending" + hex.EncodeToString(b), nil
+}
+
+// pendingSeal fills teams.sealed_name for the same instant, and for the same
+// reason: the column's CHECK demands a blob of at least 29 bytes, and the
+// real seal cannot be computed until the row id exists (SealTeamName binds
+// the name to the id). Overwritten in the same transaction.
+var pendingSeal = make([]byte, 29)
+
+// isUnique reports whether err is a UNIQUE constraint violation.
+//
+// modernc.org/sqlite returns extended result codes on its own error type,
+// but the constant for SQLITE_CONSTRAINT_UNIQUE is not exported in a form
+// this package can depend on without pinning the driver's internals. The
+// message is the stable surface: SQLite has spelled it "UNIQUE constraint
+// failed" since 3.8. A false negative here costs a clear ErrLabelTaken and
+// returns ErrUnavailable instead, which is the safe direction to fail.
+func isUnique(err error) bool {
+ return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
+}
diff --git a/internal/home/directorydata/provisionids_pin_test.go b/internal/home/directorydata/provisionids_pin_test.go
new file mode 100644
index 0000000..9190528
--- /dev/null
+++ b/internal/home/directorydata/provisionids_pin_test.go
@@ -0,0 +1,33 @@
+package directorydata_test
+
+import (
+ "testing"
+
+ "amadan.net/oficina/home/internal/home/directorydata"
+ "amadan.net/oficina/home/internal/operator"
+)
+
+// TestTeamSQIDMatchesOperator is the reason directorydata is allowed to keep
+// its own copy of the sqids codec. operator imports directorydata, so
+// directorydata cannot import operator back; this external test package can
+// import both, and fails the moment the two stop agreeing about what a
+// team's sqid is. A drift here would mean a team addressed by two different
+// identifiers depending on which package answered.
+func TestTeamSQIDMatchesOperator(t *testing.T) {
+ for _, id := range []int64{1, 2, 7, 42, 255, 1000, 65535, 1 << 20, 1<<31 - 1} {
+ mine, err := directorydata.TeamSQID(id)
+ if err != nil {
+ t.Fatalf("directorydata.TeamSQID(%d): %v", id, err)
+ }
+ theirs, err := operator.TeamSQID(id)
+ if err != nil {
+ t.Fatalf("operator.TeamSQID(%d): %v", id, err)
+ }
+ if mine != theirs {
+ t.Fatalf("id %d: directorydata %q, operator %q", id, mine, theirs)
+ }
+ }
+ if _, err := directorydata.TeamSQID(0); err == nil {
+ t.Fatal("id 0 must be refused")
+ }
+}