Skip to content

v0.21.0 — measurement store split, supply-chain bounds, panel accessibility

Choose a tag to compare

@Jason-Vaughan Jason-Vaughan released this 06 Sep 04:01
· 37 commits to main since this release
1f90044

TangleBrain 0.21.0 — the first release since 2026-08-01, covering 25 commits.

Upgrade note: nothing in this release changes how an existing install behaves on upgrade. The measurement store is now two files (totals.json beside usage.jsonl) and compaction can fold old rows into the permanent half, but nothing triggers compaction yet — the size cap that invokes it is still to come (#101).

If you use the delegate extra: this release requires mcp >= 2, < 3. Installs that need mcp 1.x should stay on 0.20.1, the last release that allowed it.


Added

  • Old rows fold into the lifetime totals, and the totals are written first — a compaction that
    adds the oldest rows into totals.json and only then removes them from usage.jsonl. This is
    the write half of the measurement store; nothing triggers it automatically yet, so no install
    changes behaviour on upgrade.

    The order of the two writes is the whole feature. Interrupted between them, a compaction
    leaves rows counted in both halves and the figure reads too large. The opposite order would drop
    rows before anything recorded them — a smaller figure, no evidence, nothing left to recompute
    from. Over-counting is a bug; under-counting is the loss of the only claim this product makes
    about itself, so both directions are pinned by tests that force the interruption rather than
    waiting for one. What the ordering does not buy: nothing is persisted to mark which rows were
    folded, so an inflated figure is not attributable and does not correct itself — the automatic
    trigger has to settle whether a watermark is owed before an unattended crash can inflate it.

    Writes are durable, not merely atomic: the staging file is fsynced before the rename and the
    directory after, because os.replace orders the two writes against a killed process but not
    against a power loss. A totals file that is present but unparseable is not folded onto —
    reading it as zeros is right for a rollup, which renders, and wrong for a writer, which destroys,
    so compaction refuses and leaves both halves on disk.

    The fold runs the same summation the read path runs, so a figure cannot change merely because
    rows moved across the seam — a property test asserts exact equality across generated logs, and
    across six successive folds with appends between them. Rows that stay are copied
    through unparsed, so a field added by a newer TangleBrain, or a line left torn by an interrupted
    append, survives the rewrite that keeps it.

    Writing the totals also completes their forward-compatibility contract, which until now existed
    only on the read side. Ignoring an unknown key on read would have deleted it on write, so the
    first compaction run by an older TangleBrain would have permanently destroyed fields a newer one
    wrote. The writer now carries unrecognised fields through at any depth. Carrying is not
    maintaining — an older version cannot add the folded rows to a field it does not know, so such a
    value goes stale rather than being lost — and that limit is stated in data-model.md and
    boundaries.md rather than implied.

    A process-level lock covers the whole read-fold-truncate. It cannot cover a second TangleBrain
    process: an append during a compaction is written to the file being replaced and lost, and two
    overlapping compactions read the same stored totals so the later write discards the earlier fold.
    Accepted for a single-operator local tool and written down: an advisory file lock would hold on
    POSIX only, and a guarantee that silently does not hold on a supported platform is worse than a
    stated limitation.

  • Lifetime totals are a file of their own — totals.json, written beside the usage log in the
    state root and read by tanglebrain --stats and the GUI panel. The spend-avoided headline is now
    the sum of stored lifetime aggregates and the per-task rows still on disk, rather than a re-read
    of every row ever written. This was the read half; the fold that writes it is the entry above.
    An absent file reads as zeros, so on a log that has never been compacted the figure is
    byte-for-byte the figure of the previous release — verified against the live 36-record log, where
    every shared figure is identical and only the two labels below differ.

    Splitting the two is what lets a later release bound the row window without the lifetime claim
    shrinking underneath it — and it is what makes a wrong figure attributable, since a discrepancy is
    then either in the stored totals or in the window, never diffused across both. The format carries
    every question --stats asks of it, including the set of pricing revisions the figure spans:
    that evidence lives per-row today and compaction destroys it, so it is captured at the format's
    birth rather than retrofitted once the rows are gone. The live log already spans two revisions.

    Forward compatibility is the same contract the usage record honours — an unknown key is ignored,
    a missing key reads as zero — and for the same reason: several versions of TangleBrain write this
    store over its life and an append-only store has no migration story. There is deliberately no
    schema-version field; a version number advertises that a breaking revision is possible, and the
    additive-only contract exists so that one never has to be. An absent, truncated, or corrupt file
    degrades to a smaller number, never to an error.

  • The install contract is published as data — an installReference key in
    .claude-plugin/marketplace.json naming the marketplace entry (ref: "main",
    autoUpdate: true), the plugin key to enable, and the console script that must be present.
    Until now the only statement of how TangleBrain should be installed was prose in README.md,
    and prose is not checkable: the entry /plugin marketplace add actually wrote carries no ref
    and no autoUpdate, a shape that reads as governed, resolves to nothing wherever the plugin is
    not already cached, and let a local checkout sit four releases behind in silence. An install
    monitor caught the incomplete entry but could not repair it, because there was nothing canonical
    to repair it to.

    tests/test_plugin_manifest.py derives every field from the thing it describes rather than
    restating it — the marketplace key from the manifest's own name, the enabled-plugin key from both
    manifests, the required command from pyproject.toml's console scripts, the repo from the
    plugin's homepage. A confidently wrong install reference is worse than none, because a reader who
    trusts it stops looking for a better source.

  • A written deprecation policy — docs/design/deprecation-policy.md.
    The policy existed in practice (usage-log fields additive-by-default, --route retained as an
    accepted no-op) but had never been stated, so it could not be relied on from outside the repo or
    applied consistently inside it. It covers the four consumer-facing surfaces — CLI flags, the MCP
    tool surface, the usage-log record shape, and the two HTTP surfaces — and states the announcement
    path and horizon (deprecated in N, not removed before N+2, never in a patch).

    Two things it settles that were genuinely open. Pre-1.0 honesty: semver says 0.y.z owes
    users nothing, so the document separates what semver permits (everything) from what TangleBrain
    commits to anyway, and says plainly that those commitments are policy rather than semver until
    1.0. Dependency floors: raising a major floor is a breaking change even when no TangleBrain
    code changes, because the user's install resolves differently. Users on the older major are not
    stranded but pinned — every previous release stays on PyPI, and the CHANGELOG entry names the
    last release that allowed the old major rather than leaving it in an issue thread.

    It also draws a line it does not cross: whether a new major is worth adopting given its
    transitive dependency surface is weighed on the issue that makes the call, not pre-authorized by
    the policy. The policy governs how a floor is announced, not whether to raise it.

  • key_ref: file:PATH now warns when the credential file is group- or world-readable.
    ARCHITECTURE.md describes the intended posture as a 0600 file and nothing verified it, so
    a world-readable key was read in silence. Resolution now stats the file first and writes a
    warning to stderr naming the path and its octal mode. It warns rather than fails,
    deliberately: refusing to run would break a working setup over a condition the operator may
    have accepted, while a warning still surfaces the misconfiguration at the moment it matters.
    The notice fires once per file per process — the credential is resolved on every routed
    request, and a per-call warning would train the operator to ignore it. POSIX only; Windows
    mode-bit semantics differ, so the check is a clean no-op there rather than a guess. The
    warning never echoes the credential.

  • Failed tasks and lost failover attempts are recorded in the usage log (#100). A task that
    fails at every backend now writes a kind: "failure" record carrying the per-backend attempt
    list, and a task served only after failover carries the attempts it lost in a new optional
    failures field. Both are held out of the spend-avoided headline the way delegate records
    already are, and --stats shows a Tasks failed line (with the lost-attempt count) once there
    is anything to report. The new fields are optional and additive, so existing readers and
    pre-existing log lines are unaffected.

  • Published the design documents as docs/design/. Eight documents
    covering runtime architecture, the four API contract surfaces, the data model and what survives a
    crash, the security model, contract boundaries, observability, nonfunctional requirements, and
    operations — plus an index. They state what the project promises and why, and they name their own
    gaps rather than only their strengths. Linked from README.md and CONTRIBUTING.md, which now
    asks that changes to routing, adapters, or either HTTP surface update the matching document in the
    same PR.

  • Filed every gap the design documents disclose as a tracking issue, so each admission carries a
    fix path: the missing loopback-bind test (#98), unchecked key_ref file permissions (#99),
    unrecorded failures and lost failover attempts (#100), the usage.jsonl growth and cache-tier
    placement question (#101), and the incorrect --roster help text (#102). Three coordination-layer
    gaps found while designing a multi-backend setup are filed alongside them: no way to designate an
    orchestrator rather than round-robin (#95), --model silently stripping an orchestrator's
    delegate tool (#96), and capability routing being unable to route upward (#97).

Changed

  • The --stats block says which figures are lifetime and which are not. The heading now reads
    spend avoided (cloud-equivalent, lifetime), and the delegate tree's Linked to: line is marked
    (within the current row window); the GUI panel carries both labels too. That one line is the
    only figure in the block that cannot be folded into the permanent totals — it holds one key per
    parent task id, so its size grows without bound — and an unlabelled window-scoped split sitting
    under a lifetime headline reads as a lifetime count. No number changed.

  • State moved out of the cache tier — the rotation cursor, the usage log and config backups now
    live under ~/.local/share/tanglebrain/ (honoring XDG_DATA_HOME; TANGLEBRAIN_STATE_DIR still
    overrides everything). ~/.cache is defined as a directory any cleanup tool may delete at will,
    and nothing TangleBrain kept there was a cache: the usage log is the only record of accumulated
    spend-avoided and is never reconstructible, because prompt and response text is never persisted
    by design; a config backup is the only copy of something the operator hand-edited. Both were one
    brew cleanup from gone.

    Existing installs migrate on first run, and lose nothing. Every entry in
    ~/.cache/tanglebrain/ is copied forward before anything reads state, the originals are left
    in place
    so a downgrade still finds its history, and a single stderr notice names both
    directories. The copy is per-entry and skips what is already present, so an interrupted migration
    completes on the next run instead of skipping wholesale, and a completed one is a no-op. A
    migration that fails says so on stderr rather than raising — an incomplete log understates
    savings, and an operator has to be able to tell that from a genuinely small number.

    Two details are enforced rather than asserted. The migration copies every entry it finds rather
    than a named list of expected files, because a hard-coded list is a claim about a directory's
    contents that decays silently the moment anything new is written there. And the "every console
    script migrates before it reads state" claim is a test that derives its list from
    pyproject.toml's [project.scripts], so adding a fifth entry point without wiring the
    migration fails the build.

    This closes the placement half of
    #101; the log is still unbounded and
    that half stays open.

  • The delegate extra now requires mcp >= 2, < 3. tanglebrain/mcp_server.py is migrated to
    the 2.x API: mcp.server.fastmcp.FastMCP became mcp.server.mcpserver.MCPServer. The tool
    surface is unchanged — delegate, delegate_local, delegate_targets and delegate_many keep
    their names, parameters and behavior, and the decorators and run() are identical, so an
    orchestrator sees no difference.

    This is a breaking change for installs, announced as one per
    docs/design/deprecation-policy.md: the floor is
    load-bearing, since mcp.server.mcpserver exists in no earlier major. Users who need mcp 1.x
    should install TangleBrain 0.20.1
    , the last release that allowed it — it remains on PyPI and
    keeps working. Only users of the optional [delegate] extra are affected; the core install is
    untouched.

    Two things worth knowing before upgrading. The extra now installs httpx2 alongside
    TangleBrain's own httpx (and httpcore2 alongside httpcore), because mcp 2.x moved HTTP
    stacks — wasteful rather than dangerous, and the alternative was holding the SDK at a superseded
    major indefinitely. Total installed distributions are effectively unchanged (30 → 29 in a
    measured comparison), because 2.x declares directly much of what 1.x pulled transitively;
    opentelemetry-api and truststore are genuinely new.

  • Every declared dependency now carries an upper bound. httpx >= 0.27, < 1 and
    PyYAML >= 6.0, < 7 replace open-ended constraints. This changes what a fresh
    pip install tanglebrain resolves to: a future httpx 1.0 or PyYAML 7.0 will no longer be
    picked up silently. The bound sits at the major boundary deliberately — tighter caps buy nothing
    and create resolution conflicts for anyone installing TangleBrain alongside other packages, which
    is a real cost paid by users to prevent a break semver already announces. What made the mcp
    2.0.0 breakage expensive was not the release but the unbounded constraint that let it land in
    every fresh resolve with no announcement and no commit to blame.

Fixed

  • A backup is never left short under a valid name. The pricing and roster saves copied
    straight to their final backup path, so an interrupted copy left a truncated file that a later
    restore would read as whole — and a backup is consulted exactly when the original is already
    gone. Both now stage the copy beside the target and rename it into place, the same way the writes
    themselves have always worked. Staging names also carry a unique suffix, so two writers racing on
    one target can no longer interleave their bytes into a shared .tmp and rename the mixture in.

  • The GUI panel's colour layer now meets WCAG 2.1 AA, and is asserted rather than audited once
    (#115).
    Nine of twenty-seven pairs failed, all of them muted text or control outlines. Two
    findings are worth repeating. --text-muted cleared 4.5:1 against the page background but not
    against the two surfaces it is actually used on (4.34:1 on cards, 3.89:1 on the elevated tiles) —
    measuring against the page alone would have declared it conformant. And form fields were outlined
    at 1.09:1 against their own fill, so the boundary of a text input carried essentially no contrast;
    that outline is now its own token, kept separate from the decorative border so the fix does not
    restyle cards and tables that never needed it. Raising the rest-state outline then dropped the
    focus outline to 1.40:1 against it — passing SC 1.4.11 while quietly failing SC 2.4.7 Focus
    Visible — so the focus colour moved too. tests/test_gui_contrast.py parses the palette out of
    the stylesheet and checks every pair, plus two structural assertions that stop the pair table
    falling behind the stylesheet: every :root token must be measured or explicitly exempt, and no
    colour literal may live outside :root. Decorative borders and disabled controls are exempt by
    the explicit carve-outs in SC 1.4.11 and 1.4.3; both exemptions are recorded in
    docs/design/nonfunctional-requirements.md rather than left silent.

  • Two adapters coerced caller-supplied options past their own error contract. opts is a
    Mapping[str, object], and both the openai-compat and CLI adapters ran a bare int() / float()
    over it — so a non-numeric max_tokens or timeout escaped as a raw TypeError, past the
    AdapterError each method documents and past cli.main's handler that turns that contract into
    one clean line instead of a traceback. Both now raise the documented type. A numeric string still
    coerces: guarding the failure path is not licence to tighten the success path, and a test pins
    that half too. Surfaced by adopting mypy (#113).

  • from_entry checks the invariant it claimed in a comment. Both adapters passed
    entry.invoke.base_url and .model through with the note "validated non-None by the roster
    loader". True of any entry that came through load_roster — but Invoke is a public dataclass
    that can be built directly, and a None reaching that path surfaced as a TypeError from inside
    the HTTP request rather than as the documented AdapterError. It is now a check, with a test.

  • A delegate failure again tells the orchestrator what went wrong. The mcp 2.x migration
    silently dropped the reason: 1.x surfaced Error executing tool X: endpoint down, while 2.x
    reports a bare Error executing tool X for any exception that is not a ToolError. The tools
    now wrap AdapterError and RouterError so the message survives, and the result still carries
    is_error. Losing the reason matters more than it sounds — "endpoint down" and "key_ref file
    not found" call for completely different responses, and the operator reading the transcript is
    the one who has to tell them apart. A no-fit is deliberately not wrapped: it is a routing
    signal telling the orchestrator to do the work itself, and reporting it as an error would make a
    working delegation look broken.

  • An unreadable key_ref file now fails with a clean error instead of a traceback. The
    permission check stats the file, but the read that followed was unguarded: a file the process
    could not open (wrong owner, restrictive mode, removed mid-run) raised a raw PermissionError,
    which is outside resolve_key_ref's documented Raises contract and outside the AdapterError
    clause cli.main catches. The operator saw a stack trace where every other credential failure
    prints one line. It now raises AdapterError naming the path and the OS reason.

  • A paid last-resort backend reached by failover no longer receives the delegate tool. The
    fix for #96 asserted the pin/delegate rule at the call sites that had the symptom; the router's
    failover loop still passed a blanket inject_delegate=True, so a tier: api CLI entry that is
    not can_orchestrate was built as an orchestrator whenever the rotation exhausted. The decision
    now lives in one place — build_adapter derives it from the entry's own can_orchestrate when
    the caller has no opinion — so every current and future call site is correct by default rather
    than by restating the rule. The delegate server still passes False explicitly, which is the
    no-recursion rule and not an absence of opinion.

  • --model on a can_orchestrate entry no longer strips its delegate tool. Pinning a
    backend built its adapter without inject_delegate, so an orchestrator pinned with --model
    ran the whole task alone — no delegate tool registered, no warning, no error. The only visible
    symptom was a lower spend-avoided figure in --stats, which reads as a routing mystery rather
    than a bug. Pinning which backend serves a request is a different decision from whether
    that backend may delegate, and one must not silently imply the other. The same defect was
    present on the streaming path (run_once_stream) and is fixed alongside it; delegation now
    tracks the entry's own can_orchestrate flag on both.

  • --roster help text now states the real default. It claimed the default was the packaged
    tanglebrain/config/roster.yaml; the actual resolution is $TANGLEBRAIN_ROSTER, then
    ~/.config/tanglebrain/roster.yaml if it exists, then the packaged example. This matters more
    than a typo: the operational runbook's first diagnostic step for a mis-route is "check which
    roster is actually in play," and --help answered it incorrectly — sending an operator to read
    and edit the packaged example while their own config was live. The symptom of editing the wrong
    roster is that nothing changes, which reads as a routing bug rather than a documentation one.

Internal

  • Filed #131 for the accessibility
    surface #115 did not cover.
    The new Accessibility section names keyboard traversal,
    screen-reader semantics, motion and reflow as unaudited — and docs/design/README.md promises,
    twice over, that every gap the design docs disclose has a tracking issue. Disclosing a new one in
    prose with nothing behind it would have quietly broken that promise on the same page that makes
    it. The gap ledger and the design-overview Artifact both carry the row now.

  • Closed the observations the verify pass demoted. Two were defects rather than polish. Editing
    project-state.yaml to retire the answered contrast question had orphaned a priority: low line
    into the next entry, giving it a duplicate key that YAML silently resolves last-wins — the #92
    question's priority had flipped from medium without anything saying so. And the contrast suite's
    colour-property guard omitted border-bottom and border-right while border-bottom is used
    twice in the shipped stylesheet, so its docstring's "every colour-bearing declaration" was still
    not true. The pattern is now hoisted to module scope and shared with a new test that runs it
    against synthetic CSS for every syntax and every border side — the guard's coverage is asserted
    in-repo rather than claimed, and narrowing it now reds the suite. Also: the streaming type guard
    is a raise rather than an assert, because python -O strips asserts and a stripped guard
    would have handed the client a dict as the byte iterator.

  • Resolved the Critic findings for Chunk C. The substantive ones were a stale-claim class and a
    real threshold bug. ApiAdapter.from_entry was a near-verbatim copy of its base differing only in
    one string, and this chunk had deepened it by copying a new guard into both; the kind is now a
    class attribute and the subclass has no override. ignore_missing_imports was global, which would
    have silenced a renamed or misspelled first-party import — exactly the defect the gate was
    adopted to catch — and is now scoped to mcp.*. The mypy pin was bounded at the next major by
    reflex, but mypy adds checks at a minor, which reds untouched PRs in a lockfile-free CI; both
    dev tools are now bounded at their real breaking-change unit. The contrast suite classified a
    20.8px normal-weight value as WCAG large text, asserting 3:1 where 1.4.3 requires 4.5:1, and
    missed two pairings that exist in the shipped stylesheet (--danger on the output pane, the
    button outline on hover). Its literal guard now checks colour-bearing properties rather than
    hunting hex, so rgba(), hsl() and bare CSS names cannot slip past — and the docstring now
    states plainly what the guards do not cover (a new pairing of two existing tokens) instead of
    implying the AA claim is fully mechanical.

  • De-anchored every line-numbered citation into .py files, and retired a claim that outlived its
    correction.
    "The codebase's one deliberately broad exception handler (measurement.py:376-378)"
    appeared in six renderings; the previous commit fixed the one it happened to be editing and left
    the rest — including a normative Direction statement, which would have made the eight waivers this
    chunk legitimised read as departures. Every site now cites by symbol and points at the grep. The
    cited line ranges had already drifted: 376-378 is the linkage block today.

  • Adopted ruff and mypy as defect gates; declined the formatter and --strict, with reasons
    (#113).
    make lint now runs both, make test depends on it, and CI runs make test, so a gate
    cannot be green locally and absent in CI. Both tools sit in a new dev extra — nothing there is
    imported at runtime, so the minimal-dependency posture, which is about what a user installs, is
    untouched. Each half of the ruling was measured against this codebase first: ruff format would
    have rewritten 41 of 45 files while catching no defects, and mypy --strict reported 62 errors
    against default mode's 11, 39 of them type-arg ceremony. The rule set selects for defects and
    deliberately omits the style families, which are the formatter question under another name. The
    full ruling, and what would justify revisiting either decline, is in
    docs/design/nonfunctional-requirements.md, "Code quality gates".

  • Twelve inert suppressions removed or made real. The codebase was already written as though
    these tools were running: it carried # noqa: BLE001, N802, F401 and E731 directives, plus
    a # type: ignore[arg-type] in measurement.py that named the wrong error code. Every one
    suppressed a rule nothing had selected, so none of them did anything. That is the same defect
    #113 describes for annotations, one level up — a decision documented but not enforced. RUF100 is
    now in the rule set so a waiver that stops being needed fails the build.

  • A latent closure bug and a truncating comparison in the suite. A lambda in
    test_openai_compat.py closed over a loop variable rather than capturing it — harmless only
    because each iteration consumes its lambda before the next rebinds it — and test_roster_edit.py
    zipped two line lists without strict, so an edit that changed the line count would truncate
    to the shorter side and could still satisfy "exactly one line differs". Both found by ruff on
    adoption.

  • Packaging tests assert parsed constraints instead of the characters of a requirement string.
    The upper-bound check looked for a literal < anywhere in the requirement, which an environment
    marker satisfies with no ceiling at all (foo >= 1 ; python_version < "3.13" passed) — and the
    mcp check was whitespace-sensitive, so the semantically identical mcp>=2,<3 failed it and a
    formatter run would have redded the suite over nothing. Both now compare against the version
    specifier with the marker stripped and whitespace normalized. Verified both ways: the marker-only
    form now fails, the unspaced form now passes.

  • Retired the #90 admissions the same bundle closed. deprecation-policy.md and
    api-contract.md still described the mcp floor decision as pending — one of them in the sentence
    justifying why the policy was written when it was, so a reader assessing whether it was authored
    under pressure from a live decision got the wrong answer. The bundle applied the closed-gap rule
    to #92 and #114 and missed the issue it closed itself.

  • The design-doc gap table says what its completeness claim covers. It read "Every gap these
    documents disclose has an issue" while security-model.md discloses two ratified non-goals — no
    roster integrity check, permissions warned rather than enforced — that deliberately have none.
    The claim now scopes itself to open work and says non-goals are decisions rather than backlog.

  • installReference verified against the consumer, not only against itself. The five new tests
    prove the key is self-consistent; they cannot prove marketplace.json still loads with an
    unrecognized top-level key, which is the failure class that produced v0.20.1. claude plugin validate reports validation passing with Unknown field 'installReference'. Claude Code ignores it at load time. — ignored being the intended outcome. Recorded in operations.md with the
    command, so it is re-checkable rather than a remembered assurance.

  • The deprecation policy's own precedent is now a test. deprecation-policy.md commits that a
    removed CLI flag becomes an accepted no-op rather than an error, and cites --route as the
    standing example — but --route was declared in the parser and read nowhere, with no test
    behind it. Deleting it would have passed the whole suite while breaking exactly the forgotten
    scripts the commitment exists to protect. tests/test_cli.py now pins both halves: the flag
    still parses, and it reaches run_once as no argument at all, so it is inert rather than merely
    tolerated. Verified by deleting the flag, which now fails.

  • The deprecation policy now says what "announced as breaking" means before 1.0. The policy
    requires a dependency-floor bump to be announced as breaking; TangleBrain's release tooling maps
    the major-bump marker (the word, followed by a colon) to a major bump, which from 0.20.x is
    1.0.0 — a claim about the project's maturity that no single change earns. The policy now states
    that pre-1.0 a breaking change ships as a minor bump announced in prose, and reserves the marker
    for after 1.0. Announcement is a duty to the reader; the marker is a lever on the version number,
    and conflating them either understates the break or overstates the release.

    Note the marker is deliberately not written literally in this entry: the bump step matches it
    anywhere in the body, so it cannot distinguish a marker from a mention of one. Writing this entry
    the obvious way would have forced the 1.0.0 it exists to warn against.

  • The design-doc gap table pointed at the wrong issue for the deprecation policy. The row
    reading "No written deprecation policy" linked #90 (the mcp 2.x migration) rather than #114,
    which is the issue that actually writes the policy. #90 is where the gap becomes live — an
    mcp >= 2 floor is the first real test of an unwritten rule — but a reader following the link
    arrived at a migration rather than the policy work. A wrong link satisfies the table's
    completeness claim while defeating its purpose, which is worse than an absent row.

  • CI now runs on a weekly schedule, not only on push. A dependency can break the build with no
    commit to blame, and a push-only CI never notices: after the mcp 2.0.0 release main read
    green for days because its last run predated the release, and the failure first surfaced on an
    unrelated markdown-only PR. The scheduled run resolves dependencies fresh — no lockfile, no cache
    — so it sees what a user's install would resolve to today. A lockfile was considered and rejected
    for CI: a green run against frozen versions says nothing about what a fresh install gets, which is
    the exact signal wanted here. workflow_dispatch is enabled alongside it so the canary can be
    exercised on demand rather than only by waiting a week.

  • tests/test_packaging.py asserts the rule, not one instance of it. The upper-bound check
    covered only the mcp requirement; it now iterates every requirement in project.dependencies
    and in every optional-dependencies extra, so a dependency added to a new extra is held to the
    same rule without anyone remembering to extend the test. The mcp-specific test was not deleted
    in the consolidation but sharpened to assert < 2 rather than merely an upper bound —
    mcp >= 2, < 3 satisfies the general rule while shipping a delegate extra that cannot import,
    so the two tests now fail on different mutations rather than restating one another.

  • observability.md's first gap now carries its issue. "No unlinked visibility" was the
    one gap in the design set with no tracking issue behind it, which broke README.md's claim that
    every disclosed gap has one. Filed as #123 and linked from both, with the actual defect stated:
    a lost TANGLEBRAIN_TASK_ID hop and a genuinely parentless sub-call land in the same bucket, so
    the count answers no question.

  • The design-doc gap table lists every gap the documents disclose. docs/design/README.md
    claims "every gap these documents disclose has an issue" and then omitted #97 — capability
    routing ranking by cost only, named in api-contract.md. A completeness claim with a missing
    row is worse than no claim, because it stops the reader checking.

  • The loopback bind is now a tested contract, for both HTTP surfaces. tanglebrain-gui and
    tanglebrain-serve are unauthenticated by design and spend real backend quota, so the
    127.0.0.1 bind is not a default — it is the whole authorization model, and widening it does
    not weaken the posture but voids it. Nothing tested it: a one-character edit to 0.0.0.0
    passed green. tests/test_bind_address.py asserts the address handed to the server rather
    than that a server starts, since a test connecting over localhost passes just as happily
    against 0.0.0.0. It covers the three ways the invariant can be voided — widening the
    literal, binding every interface with an empty host, and adding a --host flag that leaves
    the literal untouched — the last of which the address assertions alone do not catch. No
    production code changed and no socket is opened.

  • README.md leads with the install command. A visitor arriving from the PyPI listing had to
    scroll past the pitch to find out how to install; the pip install tanglebrain line now sits
    directly under the badges, alongside a MIT license badge that makes the licensing explicit
    without a click.

  • Reconciled FEATURES.md and PROJECT-MAP.md with the current generator. Both landed in the
    repo carrying a superseded TangleClaw scaffold: FEATURES.md documented the abandoned
    file.js:line pointer format (the generator now mandates stable file.js#symbolName anchors,
    precisely because line pointers rot) and named its third section Methodologies / Engines rather
    than Governance / Engines, and PROJECT-MAP.md omitted the Tangle-Shared doc group. Both
    files now match TangleClaw/lib/projects.js, the three auto-stubbed TBD entries are resolved,
    and the <!-- describe --> placeholders are filled.

  • Issue templates now declare labels that exist. feature.md and add-backend.md asked for a
    feature label the repo has never had, so gh issue create failed outright on it and
    UI-filed issues landed unlabeled. Both now use enhancement; the backend label
    add-backend.md also referenced has been created. Every label named across the three
    templates now resolves.