Skip to content

fix: publish-html --requires-auth never actually gated with OTP - #56

Open
mayoalexander wants to merge 559 commits into
devfrom
fix/publish-html-requireotp-182059
Open

fix: publish-html --requires-auth never actually gated with OTP#56
mayoalexander wants to merge 559 commits into
devfrom
fix/publish-html-requireotp-182059

Conversation

@mayoalexander

Copy link
Copy Markdown

Summary

  • buildBespokeJsonContent() hardcoded requireOtp:false on the standalone lane and omitted it on the custom lane, regardless of --requires-auth. The requires_auth record column locked the page, but every visitor (including the owner) got the frictionless "instant access, no code, no password" modal instead of a real emailed code — and that path has no code to submit, so it looped.
  • Reproduced live on /p/mediguide-boundary: the page owner could not get past the email step.
  • Fixed by threading requiresAuth into buildBespokeJsonContent() and setting requireOtp from it on both lanes.

Test plan

  • bun test src/cli/cmd/platform-pages-verify.test.ts — 21/21 pass, including 2 new regression tests
  • Hand-verified live: set json_content.requireOtp = true on the affected page, then ran the real flow end to end — send-otp (200), code received via email, verify-otp (200, real session token), gated content unlocked with the token, anonymous request still correctly shows the OTP-mode gate (requireOtp":true in the SSR payload)

Fixes #182059.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HzGwGaksRciQ6AWi4y7WW3

mayoalexander and others added 30 commits August 16, 2026 22:30
The pre-push guard caught this, which is what it is for — an unindexed command is one
an agent cannot discover, so it may as well not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qar7NcLqNhHVcUbxrkQccW
…s no longer unbuilt

Adds `iris senders prefer` / `bind --primary` to the how-to, and says the thing that is
easy to get wrong: `default` picks the default IDENTITY, `prefer` picks that identity's
TRANSPORT — two different questions with adjacent names.

Also corrects a stale "not yet built: SN-2" line; the backfill shipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qar7NcLqNhHVcUbxrkQccW
…ntity

routerSend gains `sender`, and `mail send` exposes it.

--sender and --from answer the same question in opposite directions: --from is a raw
address nothing has checked, taking the unrouted bridge path; --sender is a registered
identity the API verifies and routes on. Passing both is an error rather than letting
whichever branch runs first decide, and --sender with --attachment/--cc is refused
because that path cannot read a channel binding.

Deliberately NOT on `imessage send`: the bridge sends from whatever account Messages.app
owns, so the flag could not honour itself — and that command falls back to local
AppleScript when the router refuses, which would send anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qar7NcLqNhHVcUbxrkQccW
Says the two constraints that are not guessable: an unverified sender is refused rather
than downgraded, and --sender needs a lead because the ad-hoc handle path bypasses the
channel bindings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qar7NcLqNhHVcUbxrkQccW
… exception

A campaign step declares its own channel, so the sender preference does not apply there —
deliberately, since the step author said "email" and a preference must not override that.
Which means a mismatch fails at delivery instead of at configuration time. Says so, and
points at the command that asks the question early.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qar7NcLqNhHVcUbxrkQccW
The list an apple_mail binding asserts. Until the bridge could enumerate accounts, that
assertion was unfalsifiable: verification confirmed the bridge answered, not that the
address was a real account.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qar7NcLqNhHVcUbxrkQccW
…aveat was wrong

This file said an unknown Mail.app account would send from the default silently. It never
did: the bridge's lookup threw -1700 and every Apple Mail send naming a from-address failed
outright — the binding path had never worked. The caveat described a plausible failure
instead of the real one, which is why it survived unexamined; it read as a known limitation
rather than a bug.

Documents `iris mail accounts`, that binding and sending both refuse an address Mail.app
lacks, and the one claim still outstanding: the account existing is not the same as Mail.app
applying it, which only a received header settles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qar7NcLqNhHVcUbxrkQccW
… way to correct it (#180584)

There was create-list and nothing else. A typo or a duplicate list was permanent from the CLI,
while ITEMS had both delete-item and restore-item. The asymmetry was the bug: a command that
creates structure with no command to correct it means every mistake is forever.

Both endpoints existed the whole time — BloqListController::update and ::destroy, routed as
PATCH /user/{userId}/bloqs/list/{listId} and DELETE /user/bloqs/list/{listId}. Only the surface
was missing, so this is a CLI addition and nothing else.

Hit for real today: `iris bloqs create` silently seeds Ideas/Todo/In Progress/Completed/Daily
Diary and its output says only {success, id, name}, so setting up a client project (anomalyco#601, GTC
MediGuide) produced TWO Todo lists, two In Progress and two Completed — on a board whose first
reader would reasonably ask which one is real. Now cleaned up with the new command.

delete-list is deliberately NOT a mirror of create-list. Creating a list costs nothing; deleting
one takes ITS ITEMS WITH IT. So it:
  - counts the items first when --bloq-id is given, and REFUSES a non-empty list without --force,
    naming the count — a number is what turns "delete list 1964" from a guess into a decision
  - WARNS when it could not count, rather than proceeding quietly. A silently skipped safety
    check reads exactly like a check that passed, which is the failure mode this codebase keeps
    finding.

Verified against the live board rather than asserted:
  delete-list 1964 --bloq-id 601   -> success, items_removed 0
  delete-list 1974 --bloq-id 601   -> REFUSED: "has 6 item(s)"
  delete-list 9999999 (no bloq-id) -> warns the count was NOT checked

Still open in #180584: `bloqs create` should report the lists it seeds, or take
--no-default-lists. Printing them would have prevented the duplication entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk
`resolve` APPENDS a resolution block, so a bug closed more than once carries
every stamp it has ever had. Both readers used `String.match()` without /g,
which returns the FIRST match — the OLDEST stamp.

That breaks the remediation path for the bug fixed in fe89daf. When a close
stamps the wrong commit, the correction is to re-close with the right one — and
the correction was invisible. #180525 was mis-stamped a8a9cc45 (fl-eco-docker,
an unrelated repo), corrected TWICE to ebbf1f7, and still displayed a8a9cc45
on both `bug list` and `bug show`. The wrong hash was what everyone read, and
nothing you could type would change it.

A record you cannot correct is worse than one that was never written: it looks
authoritative and it is wrong.

Extracted `latestFixCommit()` (matchAll, take the last) and used it at both
call sites. Four tests, including the real #180525 content and a guard that
prose mentioning a hash is not mistaken for a stamp.

Verified against production: #180525 rendered `✓ FIXED a8a9cc45` before and
`✓ FIXED ebbf1f7` after, same ticket, same data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0121oCnNCeZBewSyiQSQ8bin
… (#180633)

`iris bloqs list` hits /api/v1/user/{id}/bloqs, which excludes system bloqs — agent workspaces
and `app:*` client dashboards. On this account that silently withheld thirteen boards,
including app:pathways-dashboard, app:pathways-clinical-doc, app:drex-dashboard,
app:experience-art-dashboard, app:moody-beauty-dashboard and four agent Workspace boards.

Hiding them by default is right; a project picker should list things a human made. Saying
nothing about it is not. `iris bloqs get 11` returns System Data with its two lists and
`iris bloqs list --limit 500` does not contain it — so a board you can open by ID is absent
from the list with no indication, which is indistinguishable from not having access to it.
That is precisely how a WORKING access grant on bloq anomalyco#600 read as a failed one earlier today:
granted, checked the list, did not see it, concluded the grant had not worked.

Adds --all (include_system=1) and --type, and prints a dim note whenever neither is set.

The note is deliberately not a count. The index endpoint does not report how many rows it
withheld, and printing a number the server never sent would re-introduce the same class of
problem this fixes. Naming the flag is honest; guessing the total is not.

Needs the paired fl-api change (8a8cd43a) — before it, ?type=system returned 0 of 13 because
the hardcoded type filter was ANDed ahead of the declared one.

bun turbo typecheck: 12/12 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk
`report [title..]` is a GREEDY array positional joined with spaces, so when a callers quoting
collapses every loose token slides into the title and the report is filed anyway. Reproduced
against the same builder shape:

    ["report","search returns 0","daycare","Hutto","--json","TX","78634"]
      -> title "search returns 0 daycare Hutto TX 78634",  json=true

which is exactly the shape stored on #180697. Nothing errored. That is the actual defect — not
that reports break, but that a broken one arrives LOOKING COMPLETE, so four were filed in a row
before anyone noticed.

CORRECTION to the root cause in #180713: apostrophe stripping and repro commands truncated at
the first quote are NOT this tool. They happen in the callers shell before argv exists.
Counter-example, filed by this same command earlier today: #180691 stores "person\x27s push" with
the apostrophe intact, and #180633 kept its quotes. Anyone hunting for a sanitizer in this file
would have found nothing, which is why it is worth writing down.

So the guard aims only at what argv can still see:
  - a flag string absorbed into the title (--description, --severity, --command, --error,
    --json, --bounty) or a positional that begins like a flag -> refuse, name the token, show
    the assembled title, print the correct invocation
  - a title over 220 characters -> refuse; that is body text, not a headline

Adds --title, the form that cannot absorb its neighbours. The non-interactive error has been
telling people "--title is required" for some time while no such flag existed. yargs folds it
into the same array as the positional (verified: --title x yields ["x"]), so it needs no
separate branch.

And the part that generalises, because collapsed quoting CANNOT be reliably detected — by the
time yargs is done, --json has been eaten as a flag and the leftovers look like an ordinary
title: the success output now echoes the TITLE THAT WAS STORED. The confirmation used to print
only "submitted" and an item id, which is how four corrupted reports got past their own author.

Verified from source: absorbed-flag case refuses, 383-char case refuses, ordinary report still
files and now shows its stored title. bun turbo typecheck 12/12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk
Login only ever said "Authenticated (user 193)". A bare id is not an answer when
someone legitimately holds two accounts: here the laptop CLI authenticates as
one user and the MCP connector as another, and a board owned by the other is not
reported as locked — it is reported as "Bloq not found". You cannot notice an
account mismatch you were never shown, and today that cost a wrong
duplicate-account diagnosis before anyone checked the ids.

Prints email + id + admin flag, and WHERE the credential came from: a stale
IRIS_API_KEY in the environment silently outranks `iris auth login`, so "I logged
in as X but everything acts as Y" was previously invisible.

Resolved server-side from the bearer in hand (GET /api/v1/auth/whoami, fl-api
d11fc8dc) rather than read back from local config — local config is exactly the
value that lies when a stale env var is in play, so it reports the account the
API will actually act as. When the API cannot confirm the credential it says so
instead of falling back to the stored id; a confident wrong account is the bug
being fixed, not a missing one.

Verified against production: 200 with the right identity, 401 unauthenticated,
and the failure path exercised while the endpoint was still 404.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0121oCnNCeZBewSyiQSQ8bin
typecheck has been red on main since 18:36Z. `iris auth whoami` shipped in 7a90f491e without
regenerating the capability index, and capabilities:check is a required step:

    capabilities.json is STALE — agents cannot discover what is not indexed.
      missing from the index (1): command:auth whoami

That failure message is the point of the check. The index is how agents find commands, so a
command that exists but is not indexed is a command no agent will ever call — it works when a
human types it and is invisible to everything else.

Regenerated locally where the workspace IS present, so playbooks and skills were compared too.
CI can only see commands and how-tos (it warns "83 playbook/skill entries were NOT checked —
set IRIS_PROJECT_ROOT"), which means a regeneration run in the wrong environment can silently
drop project content. Checked the diff for exactly that: 11 insertions, 3 deletions, counts
1199 -> 1200 and 1319 -> 1320, the parent auth haystack gaining "whoami", and the new entry.
Nothing removed.

bun.lock is also dirty in this checkout from another session and is deliberately not included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk
…#180713)

Adding `--title` alongside the `[title..]` positional made the help WORSE. yargs folds an option
and a positional of the same name into one entry, so the option description won and the help
read:

    title  bug title (unambiguous alternative to the positional)   [array] [default: []]

which describes the flag while sitting in the positional slot, and no separate --title line ever
appears. Someone reading that learns neither how to pass a title nor that the flag exists — a
net loss against the original "short bug title".

Both describes now carry the same text, which reads correctly wherever yargs decides to print
it, and names the flag:

    title  short bug title — quote it, or pass --title "..."       [array] [default: []]

Verified by running `bug report --help` against a checkout with workspace deps, not by reading
the source — the merge behaviour is the whole point and is only visible in the rendered output.
capabilities.json is unaffected: it indexes command describes, not positional ones (checked).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk
The course is real and has been for months — Course/CourseChapter/
CourseProgress/CourseCertificate, scored server-side, verifiable by anyone at
/p/verify-certificate without an account. What was new is that a Bounty OS
visitor can now reach it.

The section spends most of its words on what certification is NOT, because all
three confusions are expensive:

- it is not a gate (nextStep ranks it BELOW an unclaimed balance on purpose —
  telling somebody to sit a quiz while their money sits unmentioned is how a
  dashboard loses trust)
- it is not an agreement (same shape, different thing: a signed document has a
  ledger, an audit trail and revocation that a quiz does not)
- the answer key never leaves the server, so a new question path goes through
  HunterTraining::publicQuestions() or it ships its own answers

Plus the two failures worth naming: a per-path proxy that 404s same-origin while
the upstream is fine, and an unseeded environment where `training` is null and
the section is omitted rather than emptied.

Refs #180702

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0118r7ZPdSYw7oymTNBoUiqF
… (#180704, #180706)

get_pipeline_summary returns { pipeline: [{stage,count,total_value}], totals: {cases,value} }.
The CLI read data.total_cases, data.stages[].name and .value — none of which exist — so a
system holding 2,156 cases and $17.2M reported "0 cases | $0" on both `pipeline` and `status`.
The data was reachable the whole time: `integrations exec servis-ai list_cases` returns 2,129.
Those two commands are the first thing anyone runs, so a working system looked dead.

Normalised into readPipeline() so both callers agree, with the old field names kept as
fallbacks. status also surfaces stage count and audit_flag_count now that they are free.

`audit` printed fc.patient_name in preference to the case ID, putting patient names into
terminal scrollback, screen-shares and recordings — and it is the command most likely to be
demoed on a client call. Default is now the case ID; names require an explicit --names flag.

  pipeline  0 cases | $0            ->  2156 cases | $17,241,820.12
  audit     "<patient name>: ..."   ->  "CAS112824: ..."  (--names to opt in)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wc96FNjrbQVzzL3EKEPSNo
…#180735)

`iris opportunities list --json` emitted INVALID JSON, cut mid-value at an exact 64KiB boundary,
with exit 0 and nothing on stderr. Every automated consumer therefore either failed to parse the
prefix or — worse — parsed it and acted on a partial list.

index.ts ends with process.exit(), which DISCARDS whatever stdout still has buffered when stdout
is a pipe. Interactive use never shows it: TTY writes are synchronous. Only scripts break, which
is the mode nobody is watching.

MEASURED ON THE COMPILED BINARY (source barely reproduces it — 1/10 — which is exactly why the
binary is the thing to test):

    before:                 6/6  truncated at 65536
    exit-site drain:        1/10 truncated   <- did not work
    stdout.write wrapper:   2/12 truncated   <- did not work
    writeJson at the site:  0/15

TWO WRONG FIXES FIRST, both of which LOOKED right from source. Probing bun directly explains
why neither could ever have worked:

    process.stdout.write("x".repeat(300_000))
    process.stdout.writableLength    -> 0        (nothing to poll)
    process.stdout.writableNeedDrain -> false
    process.stdout.write("", cb)     -> cb fires SYNCHRONOUSLY   (barrier is a no-op)
    console.log(...)                 -> does NOT route through process.stdout.write at all

So the pending bytes are not observable at the exit site, and console.log cannot be wrapped,
counted or drained from outside. A REAL write callback does fire after the flush — which is why
writeJson() in iris-api.ts works, and why it is the only available mechanism. The existing note
in index.ts said the fix belongs at the write site; this confirms it with measurements and
leaves the reasoning where the next person will look.

So: 622 pretty-printed `console.log(JSON.stringify(x, null, 2))` call sites across 134 files are
now `await writeJson(x)`. The transformation is OUTPUT-IDENTICAL by construction — writeJson
emits JSON.stringify(value, null, 2) + "\\n", byte for byte what console.log of the same
expression produced. Only the flushing changes.

Compact `JSON.stringify({success:false,...})` calls are deliberately left alone: they are small
error objects that cannot approach the pipe buffer, and converting them would be churn.

Four sites landed in sync functions and were caught by typecheck, not by review — the reason a
mechanical sweep of this size is safe to attempt at all. Each fixed properly rather than
reverted: a forEach became for..of (a sync callback cannot await, so the payload would have gone
out unflushed again), and printResult/renderLocalUsage became async with their single callers
awaited.

Verified on a locally compiled binary: 0/15 on the reported command, and bug list (161KB),
agents list (145KB) and bloqs list all parse. typecheck 12/12. capabilities.json unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk
…ate shipped

fl-api #180702 moves verified-but-unreleased money out of `owed` into a new
`held_cents`. This board only rendered `owed`, so the instant that deployed,
Rashad ($39) and Flo ($18) went from "owed $39.00 / $18.00" to "owed $0.00" with
nothing on the row to say the money still existed.

That is wrong in a quieter and worse direction than the overstatement it
replaced. $0.00 owed reads as "nothing pending" or "already paid" — an operator
scanning this board would conclude two hunters were square when $57 of their
verified work was sitting held.

Rendered as its own column rather than folded back into `owed`, because they are
different facts: one is money that moves if you ask for it, the other is money
waiting on something the hunter has to do. The column only appears when somebody
actually has some, so the ordinary board stays a four-column read, and the legend
says what held MEANS — a board that shows a number nobody can interpret has moved
the problem rather than fixed it.

Verified by running the real production payload through this exact expression:
$39.00 and $18.00 render, the three hunters with nothing show an em dash.

Refs #180702

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0118r7ZPdSYw7oymTNBoUiqF
d3d26d81 blocks hive run / exec / script / push / deploy / swarm / demo on the
iris-exec path. The how-to still read as though `hive run` works everywhere, and
the person most likely to hit the new refusal is someone following this page from
Claude.

Says which commands are refused, which still work (nodes list/show, tasks,
status, logs, peers, connections, queue, doctor), and WHY — the restriction is
about trust, not capability. A human typing `iris hive run` has intent; a model
that just read an email does not.

Deliberately NOT changing `iris hive run --help`: the CLI is not restricted, and
a warning in its help would be wrong for the terminal, which is where that help
is read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0121oCnNCeZBewSyiQSQ8bin
Observed in production 2026-08-17. Asked to fetch projects, the model called the MCP
with `iris bloqs list --json` — exactly as a human would type it — and got back
`Unknown command "iris"`. It then told the user it could not access the IRIS
platform. The tool taught the model to deny a capability it had.

The MCP spawns the iris binary directly with the argv it is handed, so a leading
"iris" lands in argv[0] where a subcommand belongs. Both forms are now accepted.

Expecting every model to remember that this one interface wants the binary name
omitted is a convention we would have to re-teach on every model swap — and the
default model changed three times in two days.

Tests pin both forms, that a value merely CONTAINING "iris" is untouched (only
argv[0] is a binary name), and that quoted multi-word arguments survive the shift.

One test in the first draft asserted that an unknown command is still rejected. It
passed for the wrong reason: the guard is gated on `knownCommands.size > 0` and the
registry is not loaded in a unit context, so it never ran. Replaced with an assertion
that actually exercises something, and the reason is written down — a test that
claims coverage it does not have is worse than no test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… slicing it

Observed 2026-08-17: `bloqs get 544 --json` returned 68KB, the MCP sliced it at
100KB-minus-nothing with '...(truncated)', and the agent reported it could not access
the IRIS platform. A JSON payload cut mid-object is unparseable, so the model lost
the data AND every route back to it — and read the whole thing as a failure.

Claude's own harness handles this better: write the full result to a file, tell the
model to use offset/limit/jq. That turns a dead end into an artifact. This does that
and one thing more.

It returns an OUTLINE of the payload inline — top-level keys with types, plus the key
shape of the first element of any array. In the transcript that motivated this, the
very next thing Claude did after the overflow was `jq keys` to discover the shape.
The outline answers that up front, so the model can write a useful filter on its
first attempt instead of its second.

The message is written as instructions, not as an error, and says explicitly that the
command SUCCEEDED. That sentence is load-bearing: the failure being fixed is not
'output too big', it is an agent concluding the platform is down and telling the user
so. It also suggests re-running something narrower, because reading a 68KB file is
rarely the best answer when a specific list id would do.

Truncation stays as the fallback when the spool cannot be written — a degraded answer
beats no answer.

17 tests: the complete payload survives byte-for-byte, the outline names keys and
element shapes without a round-trip, jq examples for JSON and grep/head for text, and
the success wording is asserted rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… (#180715)

The good search already existed. `bloqs items <bloq-id> --search ... --include-all` fans out
across bloq + Obsidian + Drive, returns snippets and reports per-source health. It just required
a board id — which you do not have at the moment you are searching, because finding it is why
you are searching.

Worse, the empty-result hint on `iris search` pointed AT that command:

    Widen the net: iris bloqs items <bloq-id> --search "..." --include-all

offering the one thing the reader cannot run, at exactly the moment they cannot run it.

`iris search` now takes --include-all and --source. The hint points at itself:

    Widen the net: iris search "propulsion" --include-all

and disappears once the fan-out is already on, rather than suggesting what you just did.

The bloq source is deliberately EXCLUDED from the fan-out here: the cross-board content search
this command already runs covers it without a board id. Including it would reintroduce the
exact requirement that made the engine unreachable.

Source outcomes are always reported, in both text and --json (source_outcomes), because a
source that ERRORED must not be indistinguishable from one that found nothing — the same rule
federated-search.ts already states for skipped sources.

Verified from source: the default path prints the corrected hint; --include-all prints the
"Obsidian & Drive" section plus the health line "obsidian 0 · drive 0" and drops the hint.
typecheck 12/12.

Nothing was rebuilt — this is a front door on an engine that was already working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk
…laces (#180717)

mcp-serve.ts ships a hand-written subcommand list in the tool description. It is the first thing
every MCP agent reads, and large parts of it name commands that do not exist. Verified each line
against the live CLI rather than trusting it:

  memory: advertised "store, search, query, entities"
    iris memory store    -> Error: Did you mean show?
    iris memory query    -> Error: Unknown argument: query
    iris memory entities -> Error: Unknown argument: entities
    memory is an ALIAS for bloqs; it self-describes as "manage knowledge bases (bloqs)".
    There is no memory store and never was.

  outreach: advertised "send, campaigns, templates, status"
    real: list, show, create, update, delete, apply, approve
    ZERO overlap. Campaigns live under a separate `outreach-campaign` command and per-lead
    steps under `outreach-send`, which is what the advertised names were reaching for.

  workflows: "cancel" does not exist
  invoices:  "get" does not exist — the verb is `show`
  brands:    "get" does not exist — the verb is `show`
  pages:     "update", "sync" and "history" do not exist — they are `set` and `versions`
  integrations: "list", "status", "test" do not exist — they are list-connected /
    list-available / list-integrations / list-tools
  mail:      "inbox" does not exist — it is `accounts`
  partials:  "update" does not exist — it is `set`
  leads:     "gate" does not exist — it is payment-gate / gate-all / update-gate

The Examples block was wrong the same way: it told agents to run
`iris outreach send --lead 12345 --channel email`, which is not a command.

Every line is now taken from the actual command tree. This matters more than an ordinary docs
fix: an agent that reads "memory store" will try it, get "Did you mean show?", and conclude the
feature is broken rather than absent — the wrong map produces confident wrong behaviour, not a
visible error.

Worth a follow-up: this list is hand-maintained beside a capabilities.json that is GENERATED
from the same command tree and already indexes 1,200 commands. Generating it would make this
class of drift impossible instead of merely fixed.

typecheck 12/12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk
On every other command in this CLI `search` means "search my data". Here it meant "scrape
Google Maps", so `iris venues search "day care"` answered with Care.com, a Yelp page for
Honolulu and a Baltimore directory — none of them the caller.s venues, and nothing in the
output saying the source was the open web. The noun promised one thing and delivered another.

Default is now the venue list you own. The web lookup is a real capability and is NOT removed —
it moves behind --web (the existing `scrape` alias still reaches it), and the local result
footer names it explicitly rather than quietly owning the noun:

    Search the open web instead: iris venues search "studio" --web

Caught while wiring it: the venues index reads `query`, NOT `search` — `venues list` proves it.
Sending the wrong parameter name would have returned EVERY venue and looked like a working
search, which is the same failure shape as the bug being fixed.

Verified from source: `venues search "studio"` now returns Blue Mark Studios, The Beaumont
Studios, ANOMALY Studios and Black Swans Studio — real records with ids and cities.
typecheck 12/12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk
…t (#180537, #180539)

`iris bounty create` set is_public: true unconditionally. Creating a campaign and
offering it to creators were the same act, so terms you meant to review were already
live the moment the command returned — anomalyco#698 stood in front of creators carrying
$11,998 that way. Publishing is now opt-in via --publish; without it you get a draft.

The trap underneath is that the flags MIX UNITS: --rate-per-mille is CENTS while
--budget, --reward-tiers and now --amount are DOLLARS. Nothing in the output ever
restated them, so a 100x slip was invisible until it was live. Every amount is now
echoed back converted, grouped ("$10,000.00", not "$10000.00" — an order of magnitude
is one glyph in a run of zeros, and it is the error worth catching), on a real create
as well as a dry run. A number you first see after publishing is a receipt, not a check.

--dry-run prints that preview and exits without touching the API.

--amount (#180539) fills the other half: gig/fde/task are priced by
FixedAmountCalculator, and proposal_metadata.amount.fixed_cents is the only one of its
four sources that exists at creation time — the rest are role/proposal records this
POST has not created yet. Without it every engagement bounty was created worth nothing
and repriced by hand.

Verified from source: draft by default, is_public true only with --publish, $750 gig
amount reaching proposal_metadata, and the mixed-unit preview reading in dollars.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6qNMrGDtt1Dr2gMC6PKer
The reported symptom — `bounty create --json` printing an application-form schema
instead of the error — was already guarded before the payload is composed, and has
been since the command was written on Jul 12 (74e6790). The reporter's binary predated
it; the exact command now prints exactly the error they asked for, exit 2.

The sentence under the report is the live defect:

  "Which is a perfect error message — it is just unreachable under --json,
   the mode a script would use."

handleApiError never looked at --json. Every failure branch wrote clack prose to
STDERR and nothing at all to stdout, so a JSON consumer got an empty stream on 401,
402, 403, 404, 422 and 500 alike — byte-identical to a successful call that found
nothing. That is how the reporter read a failure as a creation and went looking for a
bounty that did not exist. Same shape as everything in ARCHITECTURE_GAPS.md: a signal
that cannot distinguish "it broke" from "there was none".

Now every branch emits one parseable object on stdout — {success:false, error, status,
action} — keeping the sanitisers (#57646 model errors, #162342 SQL leaks) and carrying
the actionable parts structured rather than flattened into prose: 422 field errors stay
a map, 402 forwards the whole remediation payload with its checkout_url and cli_command.
The human path is untouched.

requireAuth had it worse: the near-universal `if (!token) return` exited ZERO with
empty stdout, so an unauthenticated --json run was indistinguishable from a successful
empty one. It now answers in JSON and exits non-zero.

Read from argv rather than threaded through a parameter: ~760 call sites across ~104
files, all `handleApiError(res, action)`. An opt-in argument fixes only the sites
someone remembers, and the complaint is precisely that scripts hit the unfixed path.

Verified against the built binary: `iris bounty stats 999999999 --json` went from empty
stdout to a jq-parseable object. 12 new tests; the platform suite's 23 failures are
unchanged from HEAD (pre-existing), 399 -> 411 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6qNMrGDtt1Dr2gMC6PKer
Lockfile drift, surfaced by a local build — package.json and the release tag were both
already 1.3.180 while bun.lock still recorded 1.3.162. Not part of the bounty/--json
fixes; committed separately so those two diffs stay exactly what they claim to be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6qNMrGDtt1Dr2gMC6PKer
mayoalexander and others added 7 commits August 24, 2026 12:43
Junaid's Aug 23 call named "the one big thing that is missing is
actually KPIs" as the gap in the RevOps build. Adds objectives + key
results (quarterly, goal-scoped) and a separate KPIs dataset (ongoing
steady-state metrics, no quarter attached) — same Atlas schema-driven
pattern as the mentions dataset from #182118.

iris okr objectives list/create/show/update
iris okr kr add/update
iris okr status — dashboard across all tracks
iris kpi list/create/update/show

Live-verified end-to-end against production with placeholder records,
then cleaned up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzGwGaksRciQ6AWi4y7WW3
…shes

doctor [name]: catches the version/steps trap where a playbook has real
"### step:" blocks in its body but frontmatter's `version` isn't EXACTLY 2
(parsePlan does `fm.version === 2 ? 2 : 1`, an exact-match not a floor) --
steps silently never execute and `run` falls back to a raw text dump with
no warning. Also surfaces shadow copies (Skill now tracks every discovered
location for a name via Skill.locations()/Skill.duplicates(), not just the
first-found winner) plus the existing validatePlan() issues, in one sweep
over every playbook.

verify <name>: replaces the manual curl/grep/json-parse loop after a
publish with one command -- local file parses, API registry has the row,
registered content matches the local file (a publish 200 says the request
succeeded, not that the body sent was current), and the live public page
actually returns 200 when scope is public.

Found 3 live, currently-public playbooks broken by the exact bug doctor
targets (agentic-loop, x-ads, iris-hive) -- fixed at the data layer
(playbook frontmatter, separate commits/repo) and confirmed clean with
verify. Filed #182230 for the underlying exact-match coercion in
executor.ts, which is unchanged here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkFsjYp3XWwDQBeTH6cEqi
Export fmtPct from platform-okr.ts so it's testable in isolation, and
lock in the null-target, zero-target, no-reading-yet, and real-0%
cases — the em-dash vs. 0% distinction is the one this display logic
is easiest to get wrong silently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzGwGaksRciQ6AWi4y7WW3
Two things a concurrent rebase dropped, recovered:

1. THE HIVE REGISTRATION. `iris hive vault` was gone — platform-hive.ts no
   longer imported or registered VaultCommandExport, so only the top-level
   `iris vault` survived. That is the placement specifically asked for: vault
   belongs next to `hive fs`, the primitive it is built on. Re-added; both
   paths now resolve and both are in the capability index.

2. THE SELF-NODE FIX (was 317b7d6, dropped from HEAD entirely — the code had
   no detectLocalNodeId at all). `hive vault status` counted the local node's
   blobs as "this machine" AND listed that same node under "could not reach",
   in one breath. On a two-node mesh that is half the fleet described wrongly.
   detectLocalNodeId() now excludes self before dialling, using the daemon's
   own node_id rather than os.hostname() — macOS rewrites the hostname on every
   mDNS collision, so a hostname comparison matches the wrong machine or none.

Also reindexes capabilities: 1323 commands · 1517 capabilities. The pre-push
hook was right to block on this — a capability that exists but is absent from
the index is undiscoverable, which is the same defect family as a node
advertising what it cannot do.

Verified: typecheck clean (0 errors across the workspace), 31 vault tests pass,
`iris hive vault --help` lists all six subcommands, and both `iris hive vault`
and `iris vault` appear in capabilities.json.

NOTE FOR WHOEVER REBASES NEXT: this is the second time these changes have been
silently dropped by a `pull --rebase` in this repo (26606af, then 317b7d6).
Both were recovered by cherry-pick only because a typecheck failure or a stale
capability index happened to surface it. Nothing announces the loss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DezvuWKN7k5zUBtJ44ZZM9
…are gating, brand-correct how-to social

Three independent changes recovered from a stash that a parallel session's
work had been swept into. Each stands alone:

mcp call / mcp tools (#182089) — an MCP server was reachable by a human in
an MCP client and by nothing else: not a playbook, not an agent, not a
script. IRIS was already an MCP client; it had no verb that invoked a tool.
MCP.callTool THROWS on failure rather than returning undefined, and checks
isError separately from transport success — a well-formed response whose
payload is an error is not a successful call, and conflating the two is why
MCP-backed playbook steps had to be written as `mode: human`.

Share gating (allowed-emails / allowed-domains) — threads named-recipient
and domain allowlists through apiMakePublic to `iris atlas item share`, so a
PHI-classified item can be shared with verified addresses instead of a
link anyone holding the URL can open.

How-to social (#182088) — the CTA and links followed the docs site rather
than the brand. A FREELABEL post told musicians to run `iris how-to view
<slug>` (an instruction they cannot follow) and linked to IRIS docs. Nothing
errored; the render reported success and the creative belonged to another
company. Now --url carries the brand's own link, the CLI incantation is
IRIS-only, and a non-IRIS brand with no --url warns loudly instead of
silently emitting another company's URL.

Verified: typecheck clean, 787 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzGwGaksRciQ6AWi4y7WW3
`current / target` is only correct for an INCREASE key result. Applied to a
decrease KR it inverts, and the failure is silent and flattering.

Seeding real RevOps data surfaced it immediately. A genuine KR — "cut hours
of manual effort per campaign", target 1h, sitting at 6h, i.e. its worst
possible value, nothing done yet — evaluated 6/1 = 600%, capped to 100%, and
rendered in the "complete" colour. It then dragged its objective's average
UP: the marketing_ops row read 60% when true progress was 18%.

`direction` was already stored on every KR. It was used for one thing: which
arrow glyph to print. It never touched the arithmetic.

krProgress() now measures each direction on its own terms — decrease as
target/current (guarded so current=0, a total elimination, cannot divide to
Infinity), maintain as decay from the target in either direction (overshooting
a hold-steady KR is a miss, not a win), increase unchanged. It returns null
rather than a number when a pair cannot yield a percentage, and the dashboard
EXCLUDES those from the average instead of counting them as 0 — "not measured
yet" is not "no progress", and folding them together makes an objective look
worse than the evidence supports.

The same defect existed one layer over in KPIs, which had no direction field
at all: "Lead response time", 45min against a 15min target — three times worse
than goal — rendered as 300% in the exceeding-target colour. Added direction
to the revops_kpis schema (now v2) and threaded it through create/list/update/
show, with an arrow so a reader can tell whether a low number is the goal or
the problem.

Verified live, not just in tests: marketing_ops 60% → 18%, the decrease KR
100% → 17%, lead response time 300% → 33%, and both move monotonically the
right way as readings improve (6h→3h = 17%→33%; 45min→20min = 33%→75%).

15 tests cover the arithmetic, including the two regressions by name.
Full suite: 796 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzGwGaksRciQ6AWi4y7WW3
…ndiscoverable

The pre-push gate caught this, which is the gate working. Four new commands
existed and ran, but nothing that searches for a capability could find them,
so `iris how-to search` and the agent-facing index would both have reported
"no such thing" for features that shipped.

1325 commands · 48 how-tos · 73 playbooks · 73 skills = 1519 capabilities.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzGwGaksRciQ6AWi4y7WW3
@mayoalexander
mayoalexander force-pushed the fix/publish-html-requireotp-182059 branch from 831d15c to 2dfcc6e Compare August 24, 2026 20:10
mayoalexander and others added 22 commits August 24, 2026 15:22
…d the live KPI layer

`iris kpi` was a second KPI store standing next to one that already existed and
was better. `iris bloq kpis 624` holds 19 KPIs, five computing from real data,
each blocked one annotated with WHY, with a gap map (#182060) and build order
(#182075) built on top of it. Mine was global instead of bloq-scoped,
hand-entered instead of computed, and tracked no blocked reasons. Two stores
both called "KPI" is how a number ends up in the one nothing reads, so this
removes mine rather than keeping both.

What genuinely did not exist is an objective with SEVERAL key results under it.
`bloq goals` carries one `target` string and one `--kpi` link. `iris okr` keeps
that job and nothing else — it is now a goal layer, not a measurement layer.

A key result can now REFERENCE a KPI (`--kpi-bloq 624 --kpi k_zrl88kmtov`) and
read its value live instead of having it retyped and left to drift.

The reference is deliberately strict about what it will claim:

  - The KPI is the source of truth for the READING. When it has none — 14 of the
    19 on anomalyco#624 are blocked — the key result is UNMEASURED and says so, naming the
    blocking reason. It does NOT fall back to the KR's own current_value, which
    defaults to 0: that fallback rendered a confident "0%" for a metric nobody is
    computing, the same defect as the decrease-KR bug one commit earlier, and it
    was caught the same way — by pointing it at a real blocked KPI and reading the
    output instead of trusting it.
  - The TARGET still falls back, because a KPI can declare a goal before it
    measures against it.
  - A reference to a KPI that no longer exists renders loudly as missing rather
    than silently showing stale local numbers.
  - Unmeasured and blocked key results are EXCLUDED from an objective's average
    and reported as "(+N unmeasured)", never averaged in as zero.

Verified live against bloq anomalyco#624:
  MRR              99 / 5000        2%   ← live
  Logo churn       25% / 5%   ↓    20%   ← live, decrease applied
  Lead-to-Contact  not measured / 60%    ← blocked: no population query
  crm_ops objective reads "25% avg across 1 KR (+1 unmeasured)"

Note: evolving revops_key_results to carry the reference orphaned its existing
rows (Atlas #181628 — the read path only sees the newest schema version). Rows
were backed up and re-seeded. Anything with real data in it needs that handled
before a schema change, not after.

18 tests here; full suite 799 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzGwGaksRciQ6AWi4y7WW3
1324 commands · 48 how-tos · 73 playbooks · 73 skills = 1518 capabilities.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzGwGaksRciQ6AWi4y7WW3
…ility lives in its flags

The haystack was built from command name + aliases + one-line describe. Option
names and their describe text were never read, so the index knew what a command
was CALLED but not what it could DO.

Found by trying to use it: `iris atlas:item make-public` carries --allowed-emails
and --allowed-domains, whose entire purpose is gating a shared link to named
people or domains. Searching "gate", "allowed emails" or "restrict who can read"
returned NOTHING across all 1518 capabilities — the only place that language
exists is an option description. The feature shipped hours earlier and was
already unfindable.

Same failure family as a command that registers but is unreachable, one layer up:
a capability nobody can find is one nobody uses.

Verified: gate · allowed-emails · allowed-domains · phi · verified now all index
against atlas:item make-public.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzGwGaksRciQ6AWi4y7WW3
`iris playbook items` lets a playbook hold the written SOPs a person follows
(as Atlas items, attached BY REFERENCE) alongside the skills an agent runs.
The models, controller, CLI and public gallery rendering have all shipped for
a while — and it went almost entirely unused. Every playbook's contents were
empty, because nothing in the naming said the capability existed.

Nothing here changes behaviour. It changes whether anyone can find it:

- `items` describe now names what it is for rather than restating the noun,
  plus an epilogue explaining the two kinds of thing it holds and the
  by-reference model (edit the Atlas item, every playbook carrying it
  updates — nothing is copied, nothing goes stale).
- `add` describe and --bloq-item/--skill help say what they attach, with two
  worked --examples.
- The empty state teaches the commands instead of only saying "(none)". That
  is the one moment a person is definitely looking at this surface, and it
  was being spent on a shrug.

Adds how-to `playbook-sops-and-skills` covering the full path: draft an SOP
from a transcript, publish it as an Atlas item, attach it, assign roles —
including that a raw meeting note is NOT an SOP (it is organised around when
things were said, not what someone has to do) and the version-bump semantics
that expire acknowledgements.

Follow-up for a provisioning verb (fork/seed a team's library from templates)
is tracked separately; the container is done, only provisioning is missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01792epDfkpAJTSXPpK2HmnU
…files (RO-7 #182271)

The RevOps KPI layer was built against one revenue model: a B2B SaaS funnel.
The customer it was built toward — GTC MediGuide (bloq anomalyco#601) — is TELEHEALTH,
where revenue operations means the revenue CYCLE: eligibility, prior auth,
coding, claim submission, adjudication, denials, A/R. Not a missing metric, a
missing half (RO-6 #182260).

Only the client can say which model applies. Waiting blocks the build; guessing
wastes weeks in whichever direction is wrong. So the model stops being a
prerequisite and becomes a parameter.

Both models share one spine — work item -> staged pipeline -> terminal outcome
-> reason taxonomy -> recovery motion -> cycle time -> cost ratio. opportunity
/claim, won/paid, lost/denied, loss-reason/CARC, re-engagement/appeal,
sales-cycle/days-in-A-R, CAC/cost-to-collect. That mapping came from laying the
HFMA MAP Keys beside our own gap map, not from analogy-hunting.

Ships:
- revenue-models.ts — typed profile registry: subscription, payer_billed,
  cash_pay. Serialisable by design so it can move to a served registry when
  agents need it.
- `iris bloq models [bloqId]` — profiles are discoverable, and it says which
  one a given bloq is running.
- `iris bloq kpis list` filters to the active profile, ALWAYS prints which
  profile answered, and distinguishes "declared on this bloq" from "DEFAULT —
  not declared". A default is not a decision, and reporting one as a choice is
  the same defect as a gate that says "gated" without saying gated to whom.
- `--model <key>` previews another profile without mutating the bloq; an
  unknown key is REFUSED, never silently defaulted, so a typo cannot read as
  a deliberate choice.
- `--applies-to` on `bloq kpis add`; `--all` to see every profile's KPIs.

Two safety properties, both tested:
- An untagged KPI applies EVERYWHERE. All 19 KPIs on anomalyco#624 are untagged today;
  if absence meant hidden, enabling this would empty the board — and a metric
  that vanishes reads as "we don't track that", which is indistinguishable
  from "we track it and it's fine".
- Filtering reports what it set aside rather than quietly shrinking the list.

Default is `subscription`, so no existing board changes meaning.

Phase 2 (837/835 ingestion, clearinghouse, CARC parsing) stays correctly
blocked. Verified live against bloq anomalyco#624.

20 new tests; 613 pass across the CLI suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzGwGaksRciQ6AWi4y7WW3
`mode: playbook` lets a step hand off to another playbook. It has worked since
the executor shipped — recursive execution, depth cap of 3, self-reference
guard, arg passing, nested run output. Verified end to end with a throwaway
parent/child pair.

Playbooks in the registry using it: zero. Because nothing in the help text,
the docs, or any existing playbook said it was possible, authors wrote "now go
and run X" as prose — a chain edge that only exists as an instruction a person
has to notice and follow.

Adds an epilogue to `iris playbook --help` with the syntax and the limits, and
a how-to covering when to chain versus when a child playbook is just a section
of the parent wearing a costume.

Both state the limits plainly, since each is currently discovered by hitting
it: nesting caps at 3, a playbook cannot call itself, INDIRECT cycles are not
detected (A→B→A surfaces as a confusing depth error), args are positional so
reordering a child's declarations silently changes every caller, and a typo'd
child name passes `playbook test` and fails only at run time.

Part of #182309, which tracks the missing primitives — branching, fan-out,
named args, and a visible dependency graph.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01792epDfkpAJTSXPpK2HmnU
…annot run a stale copy

The CLI half of HF-01 + HF-03 (#182275, #182276). Daemon half: bridge d6d2e70.

`iris scripts run` now resolves the slug to a sha256 of its content BEFORE
dispatching, and sends it in the task config. The node then runs exactly that
version or refuses — it never has to guess whether the copy it cached weeks ago
is still current.

This is what let the daemon DELETE its slug-keyed cache rather than bolt a TTL
onto it. A cache keyed on a mutable name is the part that should not exist; with
the content hash as the address, staleness is impossible by construction and
verification is free, because the address and the checksum are the same value.

Fails open, and says so. If the metadata fetch fails or an older API returns no
content, no digest is sent and the CLI prints "this run will be UNVERIFIED"
rather than implying a guarantee it did not obtain.

Tests: 12 pass / 0 fail. The important ones are cross-language — the digest is
computed here in TypeScript and verified in the daemon in JavaScript, so an
encoding or normalisation disagreement would fail every run, or worse get
"fixed" by weakening the check. Fixtures cover empty scripts, non-ASCII, CRLF
and trailing whitespace, and assert that whitespace is NOT normalised away
(normalising would give two genuinely different files one address, which is the
staleness bug reintroduced through the back door).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DezvuWKN7k5zUBtJ44ZZM9
…just doctor (#182230)

parsePlan collapsed any frontmatter `version` that was not EXACTLY the number 2
down to v1, and a v1 plan parses zero steps — so "version: 3", the string "2",
2.0, or a missing field produced a playbook that validated clean, synced clean,
and executed nothing.

The coercion was only half the bug. `iris playbook doctor` DID catch it, but the
check was implemented inline in platform-playbook.ts and only there.
`iris playbook test` calls validatePlan(), whose sole step-count rule was gated
behind `version === 2` — which a coerced plan never is. Two commands, one file,
opposite verdicts; test reported "Steps: 0 / No issues found / Valid" on an
8-step playbook.

- SkillPlan gains declaredVersion (raw frontmatter value, pre-coercion) and
  bodyStepCount (steps parsed from the body regardless of version). parseSteps
  now always runs; only EXPOSURE as executable steps stays gated on v2. Without
  both, the parser destroys the evidence before validation can see it: a
  mis-versioned playbook is otherwise indistinguishable from an honest v1 one.
- validatePlan() errors when version !== 2 && bodyStepCount > 0, naming what was
  actually declared. Silent when bodyStepCount === 0 so genuine v1 prose
  playbooks stay green.
- bodyStepCount is optional: four test files construct SkillPlan literals and a
  required field would break them for no gain (parsePlan is the only producer).

d307ad1 already removed the inline duplicate from platform-playbook.ts, so
without this commit HEAD detects the trap in NEITHER command.

Verified by reproduction, not inspection:
  old binary,  version: 3  -> Steps: 0, "No issues found", Valid
  patched,     version: 3  -> "8 '### step:' block(s) ...", Validation failed
  patched,     version: 2  -> Steps: 8, Valid
Confirmed on the installed binary, and `playbook doctor` over all 69 playbooks
reports zero new errors (no false positives on real v1 docs).

5 regression tests; 3 confirmed FAILING with the check disabled — a test that
passes either way tests nothing. Suite 205 pass / 0 fail, typecheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WcmgfXD81U3cgNv9L2ZzfL
…nent library

The compiler has always recorded props/emits/slots on every artifact so a builder need not
re-parse source, and the API has always had an index. Nothing surfaced either — so composing a
page meant remembering slugs and guessing prop names, which is the friction that makes someone
write a NEW component instead of naming an existing one. That quietly defeats the point of a
stored library.

  genesis library list [--search] [--stale]   props/emits/slots inline; search the declared API
  genesis library show <slug> [--source]      plus the JSON to paste into a page
  genesis library usage <slug>                which pages name it, nested marked, count stated
  genesis library versions <slug>             publish history + staleness
  genesis library rollback <slug> --version   restore; says every page just changed

NAMED library, NOT components, because `genesis components <slug>` already exists and means
something different — what is on ONE PAGE. One word with two meanings is the drift that makes
a CLI unlearnable, and it is the same trap that had dataset meaning two things earlier today.

The product purpose line and keywords were widened too: those are what `iris help` and agents
read, so a capability absent from them is undiscoverable no matter how good the verb is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…jects

Filing something in the wrong project is the normal case, not the exceptional
one. Until now the only remedy was to recreate the item, which changes its id
and breaks every cross-reference and public share URL pointing at it.

Pairs with the fl-api change that accepts bloq_id on update, re-homes the item
onto a list in the destination, and refuses a move that would drop a
PHI/sensitive boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01792epDfkpAJTSXPpK2HmnU
  iris bloqs update-item <id> --to-bloq <id> --to-list <id>

THE CAPABILITY ALREADY EXISTED — only the flags were missing. BloqItemController::update
has accepted `bloq_id` and `bloq_list_id` for a while, and its own comment says why:

    // Move an item to a different project. There was no way to do this at any layer —
    // not the CLI, not the API — so the only way to file something in the right place
    // after the fact was to recreate it, which changes its id and breaks every
    // cross-reference and public share URL pointing at it.

That last clause is the point. An item's public URL is /n/<uuid> keyed to its id, so
"recreate it in the right bloq" silently breaks every link already shared — which is exactly
the situation this was needed for: four published research items sitting in Published Docs
that belonged in the IRIS Capabilities epic, with their URLs already circulated.

Verified by read-back rather than by the success message, which printed "updated ()" with no
field names and would have looked identical had nothing moved:

    #182268 #182278 #182315 #182021
      now in bloq 503 / list 2171   ✓ (and gone from 522/1568)
      public /n/<uuid> still 200    ✓ all four

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DezvuWKN7k5zUBtJ44ZZM9
The CLI half of #182312. Daemon half: bridge b920bf7.

`hive selftest` now emits `ran-on-the-targeted-node` FIRST, because every other
assertion is about a machine — and if that one fails, they are all describing
the wrong one.

MEASURED 2026-08-24: three consecutive runs of `hive selftest MacBookPro`
scored 6/8, 0/1 and 4/8 with different failures each time. That was not
flakiness. At least one demonstrably executed on a different machine, and at
least one demonstrably ran on MacBookPro, so two of those scores describe two
different computers. An instrument that cannot say which machine it measured
cannot be used to decide anything.

A result that does not say which node ran it is a FAILURE, not a pass — that
silence is the pre-fix state, and treating it as "probably fine" is exactly how
three scores came to describe two machines. Omitted entirely when no target is
supplied, so callers that never named a node do not gain a phantom failure.

Tests: 18 pass / 0 fail. They cover the match, the mismatch (asserting BOTH
machine names appear, since "it ran somewhere else" is useless without saying
where), the silent case, the ordering, and the omission.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DezvuWKN7k5zUBtJ44ZZM9
fromHiveTask read `r.output ?? r.stdout`, so the merged field — which every node
sends, and which contains BOTH streams — always won. The "streams come back
separate" assertion could not pass however correct the node was, because the
mapper discarded the separation before the assertion ran.

`output` remains the fallback for nodes that predate separated streams.

Refs #182004

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DezvuWKN7k5zUBtJ44ZZM9
…you import it

Every other discovery surface requires you to already know the answer: `read`
needs a function name, `sync` needs a bloq id AND source AND path, `pulse check`
needs a keyword. On day one you have none of those. survey is the read-only
manifest that comes first — it imports nothing.

It reads BOTH the availability list and the connections list and reports where
they DISAGREE, because neither alone is trustworthy. That merge immediately
found a bug far larger than the one it was written for: on this account 8 of 16
connected sources are invisible to `data-sources list` — google-drive (3
accounts), courtlistener, tradovate, and the entire social estate
(instagram/x/tiktok/threads/linkedin, 17 brand accounts). All are callable via
`read`; none are discoverable. Anyone asking "what data do I have" gets an
answer wrong by half. Filed as #182323, scope corrected after this ran.

Also reports two numbers that are usually different — sources connected vs
sources `sync` can actually bulk-ingest (1 of 16 here, since sync only accepts
dropbox|google_drive). Conflating those is the mental-model error this exists
to prevent.

--deep counts what is inside each enumerable source, and records WHY a count
failed rather than rendering a blank: "HTTP 500", "requires: vault",
"Missing required parameters: query" are all different from "0 items".

Also fixes #182326 in the same file: `data-sources list --json` and `read
--json` printed the UI banner before the JSON, so stdout would not parse
(`Expecting value: line 1 column 1`). Same defect fixed in `playbook verify`
earlier today; same fix — gate all chrome behind `if (!json)`, no trailing
outro on the JSON path.

18 new tests over the pure helpers, including a regression test built from the
real production shape (connected + working + unlisted) and one for the
underscore/hyphen spelling split between `sync` (google_drive) and everything
else (google-drive), which a naive string compare would double-count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkFsjYp3XWwDQBeTH6cEqi
Task ids are UUIDv7 — time-ordered, so runs a second apart share a leading
prefix. Three consecutive selftests printed "task 01a0370c" and read as one
cached result being replayed; they were three distinct tasks whose ids differed
only after the eighth character.

That is the same truncated-uuid mistake that produced a wrong high-severity
diagnosis in #182312, reproduced by the tool built to catch exactly this class
of defect — a display that cannot distinguish two runs from one.

Source only: installing it is blocked on disk (volume is 100% full).

Refs #182004, #182312

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DezvuWKN7k5zUBtJ44ZZM9
…a playbook

The record → draft → publish pipeline was complete except for its first and last
links, and both were small.

DISCOVERABILITY: mic capture already existed, well-built (level meter, silence
warning, keeps the wav when transcription fails) — as `iris listen`/`dictate`.
Nobody hunting for "record" guesses either, so the capture step read as missing
when it was only misnamed. `record` is now an alias.

THE CHAIN: `playbook draft` accepted a transcript all along, but listen
transcribed, printed, and DELETED its audio, leaving the user to find the file
and re-invoke by hand. --draft (and --sop) now pin the transcript to a known
path and hand it to the real drafter — no re-transcription, one drafter, same
behaviours. The wav is kept until the draft actually succeeds: a failed draft
must never be why the recording is gone.

`agent` IS A REAL MODE NOW. The server's WalkthroughStructurer emits `mode:
agent` for every step of a drafted playbook, deliberately — a step a model
extracted from audio must not be runnable on sight (measured: "bare push" came
back as "bear push"), so promoting one to shell is a human edit where someone
takes responsibility for what runs. That is good design, but `agent` was never
in StepDef's union: it worked only by falling through the executor's `default:`
to manual. So validation flagged EVERY drafted playbook as broken — including
live-meeting-to-build-pipeline, which is what prompted this. Declaring it keeps
the runtime behaviour identical, makes the intent visible instead of
accidental, and stops the checker crying wolf on the platform's own output.

Verified live: the failure path warns on silence, refuses to draft from an empty
transcript, and preserves the recording; the drafter produces an accurate 5-step
playbook from a transcript and now passes `playbook doctor` clean.

Found and filed while testing, not fixed here: `playbook draft --name` renames
the directory but not the frontmatter `name:`, so the result is unaddressable by
the name you gave it (#182332).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkFsjYp3XWwDQBeTH6cEqi
…ion, genesis-regression

The index had fallen behind `iris data-sources survey` (7427e64), which is
committed, plus the playbook-composition how-to and the genesis-regression
playbook/skill. A capability that is not indexed is one agents cannot discover,
so a stale index is a silent feature outage rather than a bookkeeping lapse —
which is why the pre-push hook guards it.

Purely additive: verified that no existing capability name is removed by the
regeneration. The other changed lines are haystack/count fields.

Regenerated with plain `bun run capabilities`, NOT --prune: five indexed entries
have no source in this workspace and belong to a machine that has them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DezvuWKN7k5zUBtJ44ZZM9
…ething

Widening always confirms. Narrowing never needs to. Without a terminal,
widening is REFUSED rather than skipped.

The two directions are not symmetric and must not carry symmetric friction.
Narrowing is recoverable — the link 404s, someone says they cannot get in, you
widen it again, and nothing escaped. Widening cannot be taken back: once a URL
is fetched it can be cached, indexed and forwarded, and making it private
afterwards un-sends nothing.

Five subsystems had each grown their own answer and all five got it wrong in the
same direction. `genesis visibility` actually confirmed when RESTRICTING and
sailed straight through when going public (#182345) — the guard was attached to
the cheap-to-undo branch. Its warning was good information, so it stays; only
the blocking moved to the direction that earns it.

The sharper half was `if (!args.yes && !isNonInteractive())`: no TTY, no prompt,
proceed. That gave an automated caller LESS friction than a person, when the
agent is precisely the caller who cannot see the consequence. Headless now
refuses and names the flag.

New src/cli/cmd/exposure-gate.ts — one ladder (private → team → gated →
unlisted → public), one decision point, every command delegates. Wording comes
from the rung: a share carrying --password or --allowed-domains lands on `gated`
and does not claim internet exposure it does not cause.

Gated now, all verified against the rebuilt binary:
  genesis visibility <slug> <mode>            --force
  atlas make-public / atlas:item make-public  --force
  atlas:item publish --public / bloqs publish --force-public
  playbook publish --scope public             --force
  datasets feeds create                       --force

`atlas:item publish --force` already means "overwrite an item edited in the UI"
(#154763), so exposure consent there is --force-public. Overloading one flag
with two unrelated meanings would be its own bug; the asymmetry is deliberate.

exposure-gate.test.ts, 28 tests: seven narrowings asserted silent, seven
widenings asserted refused. Re-inverting the guard turns this red — which is the
point, since the original defect was an inversion and not an omission.

Measured end to end on a scratch page:
  narrow → private   no prompt, applied
  widen  → public    REFUSED, exit 1, both URLs named, still private after
  widen  --force     applied

Full CLI suite 669 pass / 0 fail. argv-mapping.test.ts caught the new flags as
registered-but-never-read on the way — `(args as any).force` is invisible to it,
and the cast was unnecessary.

Epic #182344 · G-03, G-04, G-10. Refs #182345.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019hMDLWpTgPLX5LjZPPA7wz
…asserting it

`iris playbook publish --scope private` promised "private (you)" and nothing
anywhere verified it. Genesis has had `check-public` for pages for a while; the
playbook side had no equivalent, so "private" was a claim the CLI made about the
server rather than an observation of it.

That matters more than a normal missing test because the failure is asymmetric.
If publishing breaks, the author sees an error. If PRIVACY breaks, the author
sees exactly what success looks like: the command succeeds, no URL is printed,
and the content is on the internet.

`iris playbook check-private <name>` runs two probes, both unauthenticated ON
PURPOSE — requireAuth() is deliberately not called, because the question is what
an anonymous caller gets and answering it with a credential attached is the
mistake the command exists to prevent:

  1. GET /api/v1/playbooks/{name}  → must not be 200
  2. GET /api/v1/playbooks         → the name must not appear

Being LISTED counts as a failure even when the body is withheld: playbook names
carry client and project information, so leaking that one exists is a disclosure
on its own.

The verdict is a pure function (`privacyVerdict`) so it is testable without a
network, and the rule worth pinning is that UNMEASURED IS NOT SAFE — if the
public list cannot be reached we do not know, and "could not check" must never
render as "private". 7 tests cover that plus every readable/listed combination.

Measured against the live API: genesis-regression (private) → 404 and absent
from a 26-row anonymous list, so the claim HOLDS. health-check (public) → 200,
2909 bytes, listed. The check can fail, which is the property that makes it
worth having.

Epic #182344 · G-11. Refs #182346.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019hMDLWpTgPLX5LjZPPA7wz
…ross nouns

Epic #182344 G-09. The same question had five answers depending on which noun you
happened to be holding: pages visibility, atlas make-public, playbook publish
--scope, datasets feeds, and nothing at all for components. None was a superset
of the others, so there was no most-capable surface to standardise on.

  iris exposure show <ref>     page:<slug> · note:<id> · playbook:<name>
  iris exposure audit          everything a stranger can currently reach
  iris exposure narrow <ref>   never asks — closing a door is recoverable
  iris exposure widen <ref>    always confirms, refuses without a terminal

The address grammar deliberately mirrors Genesis collection addresses (item:,
list:, bloq:) so the product has ONE addressing idea rather than two. A bare
value is inferred — digits are a note, anything else a page — and the inference
is reported rather than silent, because inference you cannot see is how you
answer a question nobody asked.

pageTier() makes the gate outrank the visibility column. /p/exposure-architecture
is visibility=public with an OTP gate in front, and a stranger gets nothing;
reporting "public" would be true about a column and false about the world.

Running the audit for the first time found 102 things reachable by a stranger.
The epic had been reasoning about 21 — the notes — because that was the only
surface with a command that could enumerate. 58 PAGES are public and nobody had
ever counted them, including dev artefacts (composition-proof,
graph-neighbourhood-proof, atlas-console-chat). That gap between "what we
discussed" and "what is actually open" IS the finding, and it existed because no
single command could ask.

The scan states its own bounds — the note walk covers 50 boards, the page list
caps at 200 — because a capped scan reported as a total is a confident partial
answer, and this epic exists to remove exactly those.

collectPublishedItems() extracted from executeListPublished so `exposure audit`
and `atlas:item list` cannot drift into two different answers to "what is
public?".

17 tests: address parsing, unknown-kind refusal, and every pageTier case
including gate-beats-visibility and draft-is-not-public. Suite 686 pass / 0 fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019hMDLWpTgPLX5LjZPPA7wz
… (#182461)

`iris leads search` asked one question — "is there a row in the leads table
matching this string?" — and reported the answer as though it answered a much
bigger one: "do we know this person?"

Those come apart the moment someone is real but was never entered as a lead.
Tyler Smith (Flo) is on the team and is written about across four boards; every
lookup returned "No leads matching", which reads as *we have nothing on them*.
Richard Delgado resolved only because a CRM row happened to exist (anomalyco#15743) — not
because the lookup was any better at finding people.

The cross-project endpoint (`bloqs/content-items`) was reachable the whole time.
Nothing called it from here. Two places assumed a person exists only if a CRM
row does:

  1. leads search queried /api/v1/leads and nothing else.
  2. federatedSearch's bloq source REQUIRED a bloqId and skipped itself with
     "no bloq context" without one — so the search that advertised covering
     everything you have written could not answer "where have I seen this name"
     unless you already knew which project to look in.

Now: both sources run concurrently, ON BY DEFAULT (`--crm-only` opts out). A
flag you have to know about would have left the default answer exactly as wrong
as it was. bloqId becomes an optional narrowing; only an unresolved USER is a
real skip.

Evidence is a LABEL plus raw counts — crm+mentions / crm-only / crm-partial /
mentions-only / none — not a 0-100 score. A number implies a calibrated model;
this is two sources and a count, and dressing that up as "confidence: 72" is the
false precision that has bitten every other instrument in this repo.

Verified live, not grepped:
  "tyler smith"    0 results  ->  15 mentions / 4 projects, incl. #182253
                                  "Call with Rashad Bernard & Tyler Smith"
  "richard delgado"  lead only ->  lead + 25 mentions / 9 projects

Two things the live run then exposed, both fixed here:

  - The one-word fallback surfaced ten unrelated @tyler_* handles looking
    exactly like answers, under a spinner reading "0 CRM result(s)". They are
    labelled `crm-partial` now and the count describes what is on screen.
  - The first cut printed "nothing written about them in any project" under
    --crm-only — asserting a sweep that never ran. That is #182461
    reintroduced inside the patch for #182461. gradeEvidence now takes
    mentionsSearched, and a test pins that an unsearched source never reads as
    an empty one.

20 tests, typecheck clean, binary built and exercised in a real terminal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BBkMsXfzoNugJXsrDC3jUJ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant