Skip to content

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

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

fix: publish-html --requires-auth never actually gated with OTP#56
mayoalexander wants to merge 554 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

mayoalexanderand others added 30 commits August 16, 2026 22:21
… work (#180527)
No quality gate in this repo has produced a verdict in days. Not red — ABSENT.
17 workflows declare runs-on: blacksmith-4vcpu-ubuntu-2404 and those jobs are
not being picked up:
typecheck.yml 11 runs stuck "queued", oldest ~12h; the last five to reach
"completed" all concluded CANCELLED — never pass, never fail
test.yml every recent run cancelled at exactly 24h0m1s, the GitHub max
job duration, which is the signature of never getting a runner
So typecheck.yml — added specifically because typecheck never ran on released
code — still never ran on released code. release.yml is the only part of CI
that has been working, and the only reason is that it uses GitHub-hosted
runners. Both gates move to ubuntu-latest.
test.yml's TRIGGERS are deliberately left alone. The suite is 1446 pass / 214
fail, and pointing it at main would make the default branch permanently red,
which is how boot-check in fl-iris-api sat broken for four days with nobody
reading it. A real red on a PR is useful; a standing red on main is furniture.
script/release dispatched publish.yml, which is committed as
publish.yml.disabled. It failed loudly — HTTP 404, exit 1, not silently as I
first described it — but the obvious entry point for cutting a release did not
work, and the procedure that does lived only in somebody's head. It now
performs it: bump, commit, push, then push a SINGLE v* tag by full ref,
because a bulk --tags push does not reliably fire release.yml.
It refuses rather than guesses:
- not on main (dev is a decoy default branch)
- dirty tree — this is a SHARED checkout, so uncommitted files are routinely
not yours, and the tag would exclude them correctly but silently
- unpushed or behind origin, either of which releases something that is not
what is on main
- tag already exists
And it runs typecheck first, because release.yml runs neither tests nor
typecheck — until the workflow above is proven healthy, that is the only thing
between a type error and a published binary.
The closing output tells you to verify on the COMPILED binary rather than from
source: --compile bundles static imports, the capability index among them, so
the two can disagree — which is how a shipped index gets tested by accident
instead of on purpose.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk
`bug close` auto-stamped the cwd's HEAD and announced it in dim text. Naming the
repo (added after #177912) was not enough: closing an iris-opencode bug from the
freelabel monorepo recorded a8a9cc45 -- an fl-eco-docker commit -- as the fix,
and a wrong hash is indistinguishable from a right one to whoever reads it next.
cwd is where you are STANDING. It is not evidence about where the fix LANDED.
The batch case already refuses for exactly this reason; the single case is the
same guess with a smaller blast radius, and it is the common case.
Now stamps only on an explicit yes:
- interactive: confirm the detected hash, DEFAULT NO, so a bare Enter records
nothing rather than a guess
- non-interactive (agent, CI, MCP): refuse and print the three real options,
including the exact `--commit <hash>` to paste if HEAD is in fact the fix.
Guessing on an agent's behalf is how five bugs got stamped with an unrelated
fl-api commit.
- `--commit` / `--no-commit` unchanged
Verified by running both paths from the dev entry: non-TTY refuses and writes
nothing (no "Recording fix" line), `--commit <hash>` still records.
Found while closing #180525, which this would have prevented.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0121oCnNCeZBewSyiQSQ8bin
The committed index advertised `senders prefer`, whose source is NOT in HEAD
— it exists only in another agent's uncommitted working-tree file. My own
earlier commit put it there, because the OLD pre-push guard read the working
tree and refused to let anything through until the index matched it. That is
the corruption described in #180517, caused by the guard meant to prevent it.
The new guard caught it on the first run, which is the point: CI checks out a
clean tree, so the entry appears as 'indexed but gone' — the unambiguous
direction, which still blocks. The first honest verdict this workflow has
produced in days was a true positive about damage the previous version caused.
Edited surgically rather than regenerated: regenerating here would read the
same dirty tree and put the phantom straight back. Formatting matches the
generator exactly (JSON.stringify does not escape non-ASCII), so the diff is
the one entry and its two counts rather than 484 lines of em-dash churn.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk
capabilities:check could never pass on a CI runner. Playbooks and skills live
in the WORKSPACE repo, not in this package, and collectMarkdown returns [] for
a directory that is not there — so a checkout of iris-opencode alone reports
all 83 of them as 'indexed but gone' and fails regardless of what anyone
commits.
Nobody had noticed because the workflow was never executing. The first honest
run after moving it onto a working runner failed on exactly this.
The check now compares only what the checkout can actually see, and WARNS
about what it skipped — a narrowed check that does not announce its narrowing
reads as full coverage, which is the failure this guard exists to prevent.
Commands and how-tos, which this repo owns, are still checked in full.
The pre-push hook has always guarded this with a directory test; the guard now
lives in the generator, so the answer no longer depends on which harness
invoked it.
Verified by simulating the CI condition (IRIS_PROJECT_ROOT=/nonexistent):
83 skipped with a note, commands and how-tos still compared.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk
…aches for first
Mirrors the fl-api side. `iris senders prefer alex-mayo-iris --order email,apple_mail`
sets the order; `iris senders bind ... --primary` promotes one channel without
restating the whole list.
`senders show` now prints the rank next to each binding — (primary), (2), … — because
"which two mailboxes are bound" was never the question an operator was asking; "which
one does this actually leave from" was.
Preferring a channel with no binding is a 422 from the API, surfaced verbatim: it would
otherwise queue a send the router refuses at delivery time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qar7NcLqNhHVcUbxrkQccW
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
mayoalexanderand others added 12 commits August 23, 2026 23:13
#182078. The filtering in `leads list` was always correct. The REPORTING failed,
and it failed silently — so six funnel KPIs were computed from a page and
published as measured fact.
Written test-first: the spec existed before `leads-scope.ts` did, and the first
run was 0 pass / module not found. Extracting the logic out of the handler was
what made it testable at all; it had been inline, which is why nothing covered it.
SIX CASES, each one a way the original lied:
· a complete answer stays quiet — no false alarm to train people to ignore
· truncation names the POPULATION, not the page size
· a hidden default filter is disclosed WITH the escape hatch
· both conditions report together rather than one masking the other
· --json carries the caveat, since that is where the bad KPIs were computed
· total below shown is bad data, not truncation — no invented negative remainder
PROVEN TO FAIL. Reintroduced the exact defect — `s.shown > s.shown`, the page size
read as the total — and three of the six failed immediately, naming the
population, the combined report, and the JSON warning. Reverted, back to 6/6. A
guard that has never failed is not a guard.
The handler now calls the tested function rather than keeping its own copy, and
the end-to-end output is byte-identical:
20 lead(s) (62 Prospected hidden — use --all · newest 20 of 28522)
Suite 573 pass / 1 fail, stable over three runs; the one failure is
`platform-mint.ts · rm <key> · --json` from another session's uncommitted work.
Refs #182078, RevOps epic #182075
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZagd8eJFQz593QjSkVG4u
…ilscale query retry + SSH policy
Four bugs found while reviewing the hive-reliability epic (cc09c9a), all
confirmed against the committed code (not WIP) before fixing:
- platform-hive-doctor.ts read an invented field `completed_tasks` instead
of the real HiveNode field `total_tasks_completed` (#182103) — always
undefined, so `iris hive doctor <node>|--all` printed "done ?" regardless
of the node's actual task count. Verified live: real nodes now show their
actual completed counts (2798, 186) instead of nothing.
- `iris hive doctor --json` was ignored on the local (no-node/--all) path
(#182105) — added when remote support was bolted on, the pre-existing
local branch was never touched. A script expecting JSON (the flag is
advertised command-wide) got decorated text and broke on JSON.parse().
Verified live: --json with no node argument now returns valid JSON.
- hive-tailscale.ts's tailscalePeers() had no retry and could not
distinguish a query failure from genuinely zero peers, unlike the
sibling platform-hive-vpn.ts readStatus() — fixed there after a real
2026-08-16 incident where one transient failure read as "not on the
tailnet". This module exists specifically as the reliable fallback
BECAUSE it doesn't inherit the primary transport's outages (#182004);
shipping without the retry its own sibling already proved necessary
undercut that. Added the same one-retry-per-binary pattern, and threaded
a queryOk signal into resolveSshTarget()'s error message so "couldn't
ask" and "asked, zero peers" read differently instead of both saying
"start Tailscale" (verified live: still resolves all 6 real peers
correctly, ~900ms).
- StrictHostKeyChecking was inconsistent for the same conceptual
operation (ssh into a Hive node) — accept-new in hive-tailscale.ts,
=no (silently accepts a changed host key forever) at 4 sites in
platform-hive-enroll.ts and platform-hive-net.ts, no documented
rationale either way. Unified on accept-new (auto-trusts on first
contact, but flags an actual key change) — the safer choice, with no
functional downside for either enrollment or established-peer SSH.
A 5th finding from the same pass, the ANSI/OSC regex in
hive-selftest-assert.ts (#182102), turned out to already be fixed in this
commit's own code — the ESC/BEL bytes are real control characters in the
source, invisible to grep, which is what made my first pass think it was
still broken. Left untouched; closing that bug as already-resolved.
typecheck clean; existing hive-tailscale/hive-selftest-assert/hive-script-
result test suites still pass (64/64).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CpfuupcXvWeSfupPYwhRQ1
…cross your own nodes
iris vault create | list | put | get | ls | status
WHY, specifically. There is already plenty of storage here — cloud:upload puts
a file on the CDN, atlas files attaches it to a bloq, bloq-sync pushes it to
Drive. Every one of those puts the bytes on a third party's disk, which rules
them out for data this org actually handles: clients/ is gitignored ON PURPOSE
because no BAA covers it, PHI cannot go to OpenAI, Tailscale has no BAA either.
The current answer to "where does this live" is often "don't store it" — which
is how a controller mapping representing two days of work survived only as an
untracked working tree on one laptop.
MOSTLY WIRING, WHICH WAS THE POINT. Identity and pairing (bridge 9a1821d), node
registry and liveness (heartbeat), capacity (#182019 already reads disk
headroom), and byte transport with integrity (#182013, sha256 both ends) all
existed. New here: chunk, encrypt, place, verify.
THE PROPERTY THAT MAKES "SOVEREIGN" MEAN SOMETHING. Blobs are addressed by the
sha256 of their CIPHERTEXT. A holding node can verify its copy is intact —
recompute, compare to the id — WITHOUT the key. Replication does not imply
clearance, so a cheap always-on replica is safe. Demonstrated on MacBookPro: it
holds 56,136 bytes for a 56,108-byte file (+12 nonce +16 GCM tag), no plaintext
markers, random header.
AES-256-GCM, random nonce PER CHUNK — never counter-derived, since two files
would then reuse (key, nonce) at the same index and GCM nonce reuse leaks the
XOR of plaintexts and forges the tag. Key lives only in ~/.iris/vault-keys.json
(0600) and never syncs.
status ASKS EACH NODE what it holds rather than reading a local ledger of what
we believe we sent, and an unreachable node reports UNKNOWN, not absent. A
partial holder is named but never counted, because a partial copy restores
nothing — counting it produces "2/2 replicas" over a file that cannot be
recovered, which is the reassuring-but-false number this codebase has spent the
week removing.
Placement is capacity-aware because it has to be: MacBookPro is at 98% disk.
A node with UNKNOWN free space is not eligible — unknown is not room.
CAUGHT IN MY OWN BUILD: put reported "wanted 2, placed 1" while status reported
2/2, because one counted remote placements and the other counted total holders.
That is #182091 (three definitions of MAX_CONCURRENT) reproduced inside a single
feature. The target is now TOTAL copies, defined once.
VERIFIED LIVE, not just unit-tested:
put -> 2/2 copies, plaintext sha256 matching the source exactly
status-> 2/2 by querying MacBookPro; names AlexMaysnow1063 as UNREACHABLE
remote-> ciphertext only, correct size overhead, no plaintext
get -> restores byte-identical
AND: deleted every local blob, then restored from MacBookPro alone —
"chunks fetched from: MacBookPro", sha256 identical.
Tests: 31 pass / 0 fail, 100% line coverage of vault-core; typecheck clean.
NOT CLAIMED, and surfaced in `status` rather than left to silence: single-writer
only (no conflict resolution), no key escrow, availability follows your devices
being awake.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DezvuWKN7k5zUBtJ44ZZM9
The pre-push capabilities:check hook caught it: iris vault create/list/put/
get/ls/status (26606af) never made it into capabilities.json, so none of
them would surface in capability search/discovery despite being fully
wired and tested.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CpfuupcXvWeSfupPYwhRQ1
…had a blind spot
The suite was red — four offenders. Three were the ratchet being wrong, one was real,
and the three were what made the one easy to dismiss.
FALSE POSITIVES (platform-vault put/get/status --user). The option IS read, through
`targetFor(n, argv)` — the handler forwards argv wholesale to a helper. The detector
already skipped wholesale forwarding, but only when the param was the FIRST argument,
so a helper taking it second read as never using it. Now matched in any argument
position, with a regression test pinning the platform-vault shape.
That mattered more than three noisy lines: a ratchet that cries wolf gets muted, and
the one true violation in the very same run hides in the noise it created.
THE REAL ONE (mint group rm --json). Registered, never read. Deactivating a spending
group from a script returned prose either way, so a caller parsing JSON got nothing
parseable on success and nothing parseable on refusal — and a refusal that produces no
output reads to a script as though the group were gone. All three exits are now
machine-readable: not_found, bound_budgets, and success. The refusal shape matters most;
it is the one a script is likeliest to misread as silence.
575 pass, 0 fail across the cmd suite.
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
mayoalexanderforce-pushed the fix/publish-html-requireotp-182059 branch from 831d15c to 2dfcc6eCompareAugust 24, 2026 20:10
mayoalexanderand others added 17 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
Sign up for freeto 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

@mayoalexander