15 Commits

Author SHA1 Message Date
xarmian 527fa47e0f fix(store): a user read no longer decrypts the TOTP secret; only the second-factor check does (BUG-4019) (#2275)
* fix(store): a user read no longer decrypts the TOTP secret; only the second-factor check does (BUG-4019)

models.User no longer carries the secret. GetUserQ and its siblings, the
member and backlink reads and the mention-recipient check therefore cannot
fail on a secret that will not decrypt. checkSecondFactor loads it through the
new TOTPSecretForVerify after the limiter charge: no secret refuses the TOTP
branch (an empty one never verifies), a decrypt failure logs once without
secret material and fails closed, and the recovery-code branch is untouched.

Claude-Session: https://claude.ai/code/session_01DQDip8AC96dA71t2UCPLTD

* fix(server): the undecryptable-secret path logs once; the read tests reach the row (BUG-4019 r1)

- checkSecondFactor writes its 500 directly: writeInternalError logged the
  same error a second time. T2 counts every ERROR record except the request
  logger's per-request line.
- T1's Stripe lookup now matches the undecryptable user (it looked up an
  unknown customer and could not go red), and ListUsers and SearchUsers are
  pinned to return the row with its 2FA flag.
- The appstore boundary allowlist no longer says GetUserQ decrypts, and
  drops the deleted decryptUserTOTP.
- Two comments no longer claim a single reader of the column.

Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8

* fix(server): 2FA on with no secret behind it warns the operator (BUG-4019 r2)

An enabled row whose secret is empty cannot verify a code, so the user is
held to recovery codes; checkSecondFactor now logs one warning naming the
user. T5 asserts it. The door tests assert exactly 500, not any 5xx, and
the test placeholder helper drops its unused argument.

Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8

* fix(server): the empty-secret warning claims only what is true at that instant (BUG-4019 r3)

user.TOTPEnabled is the request's snapshot, and a disable landing after it
also reads as no secret, so the warning no longer says 2FA is enabled; it
says there was no secret to verify a code against. T5 asserts the new text.

Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8

* test(store,server): a stored secret that decrypts to empty is no secret (BUG-4019)

The decrypted=="" guard in TOTPSecretForVerify was unexercised: the empty
case wrote '' and returned at stored=="" first, and encrypt("") returns ""
rather than sealing it, so dropping the guard left every test green. The
store test now writes a hand-sealed empty plaintext to an enabled row and
expects ErrNoTOTPSecret; a server test refuses a code computed from "" on
that row.

Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8

* test(server): the BUG-4019 test header names the 2FA paths, not one check (BUG-4019 r5)

EnableTOTP also decrypts the stored secret (to compare) and fails on one
that will not decrypt, so the header no longer says the second-factor check
is the only thing that fails. TOTPSecretForVerify remains the only reader
that returns the plaintext. Comment only.

Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8
2026-10-21 15:57:29 -04:00
xarmian 423d312b77 feat(store): @mentions enqueue a push notification (TASK-3946) (#2272)
* feat(store): @mentions enqueue a push notification (TASK-3946)

A comment create or edit enqueues a 'mention' notification for each user
the comment_mentions index newly records, keyed mention:<comment>:<user>.
Never on a tombstone, import, cross-workspace copy or the backfill: the
reindex returns the new users and only the create and edit sites enqueue.

The author is never told. A mentioned user who cannot see the item
(non-member, no-grant guest, restricted member outside the collection)
gets nothing (binding rule 645fab20). On create a mention supersedes the
user's comment/reply row; on edit a written mention deletes the user's
still-pending comment/reply row for the comment, and a row already sent,
skipped or expired stays (ruling f0fc5933).

Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP

* fix(server): an edit's mentions are from the editor (TASK-3946 r1)

The edit handler passes the request's actor to a new UpdateCommentAs, so a
mention a platform admin adds to another user's comment is from the admin,
the admin's own @-mention is skipped, and the author is told when mentioned
(ruling e6a86278). UpdateComment(id, body) stays for tests and attributes
to the author.

Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP

* test(appstore,store): review the mention helpers into the fence door (TASK-3946)

The fenced comment create and edit now reach the mention enqueue and its
visibility read. Each newly reachable helper joins
allowedStoreMethodsInsideDoor with what it writes, confirmed by reading
it: only enqueueNotificationsCountTx (the existing INSERT) and
enqueueMentionNotificationsTx (the pending comment/reply supersede DELETE)
write, on the fence's tx. The mention site guard follows the
UpdateComment -> updateComment rename.

Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP

* docs(store): qualify the reply-wins note; GetUserQ decrypts (TASK-3946 r4)

The one-"reply" rule on enqueueCommentNotificationsTx yields to a mention,
and the fence door's GetUserQ entry names the in-memory TOTP decrypt
(ruling 864f5506).

Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP
2026-10-21 10:12:15 -04:00
xarmian 104f809e67 feat(store): enqueue push notifications at four store sites (TASK-3937 part B) (#2268)
* feat(store): enqueue push notifications at four store sites (TASK-3937 part B)

notifications.go writes queue rows on the caller's transaction, only for a
user with a registered device: item assignment (create and update),
comment and reply, fenced (app) comment, and reminder fire. The account
claim also deletes the old holder's devices, queue and settings.

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3

* fix(store): comment recipients collected without a closure so the appstore boundary walk resolves them (TASK-3937 part B)

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3

* docs(store): with the enqueue built, the Go comments say it exists (TASK-3937 part B)

A (07dc06285) future-tensed every sentence about the enqueue, because
part B had not merged. In part B's tree they read as false, so the
current description lives in Go comments:
- notifications.go's header: rows only for a user with a registered
  device, so nothing is written until TASK-3938; idempotent per
  (user, dedupe_key).
- migrated.go's push note.
- the settings model.
- nulExcluded's contract header.
Migration 155 is NOT edited: merged migrations are immutable, comments
included, so its owed-tense prose stays as history, and the
notifications.go header says which description is current.

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3

* docs(store): the enqueue says recipients are gated at send, and mentions are TASK-3946's (TASK-3937 part B r1)

Ruled on part B r1. notifications.go's header now says recipients are
not access-checked at enqueue: a removed member stays created_by and
possibly assignee, and the dispatcher (TASK-3939) is to re-check
membership and visibility at send. It also says the 'mention' kind is
enqueued by TASK-3946, from comment_mentions, not here.

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
2026-10-21 03:31:18 -04:00
xarmian 1e9b87759e feat(store): @mention index on comments (TASK-3917) (#2243)
* feat(store): comment_mentions index, parser, backfill (TASK-3917)

Index of which humans a live comment @-mentions, written in the comment's
transaction at every writer (create, edit, tombstone, fenced app doors,
import), with a startup backfill. Migrations 154 (sqlite) and 123 (pg).

Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE

* test(appstore): allow-list reindexCommentMentionsTx in the fence boundary (TASK-3917)

Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE

* test(appstore): the comment write census permits comment_mentions (TASK-3917)

Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE

* fix(store): comment mention index r1 fixes: skip imported, cap, parser rules, created_at, guard (TASK-3917 r1)

F1 imported comments are never indexed (import, edit, backfill); F2 cap of
50 names; F3 parser "/" "\" "_" "." non-ASCII rules; F4 guard rewritten with
per-decl statement counts over internal/ and cmd/; F5 created_at follows the
comment (edit time for a newly added mention); F6 FOR KEY SHARE on PG;
F8 backfill logs per-comment failures.

Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE

* test(store): pin the backfill created_at against an aged comment (TASK-3917 r1)

Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE

* fix(links,store): follower and prefix rules, cap after resolution, guard ignores comment lines (TASK-3917 r2)

Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE

* test(links),docs(store): mid-word "_" prefix row; F6 skip comment names what reopens it (TASK-3917 r2)

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3

* fix(links): linear URL check for "/" before "@"; store docs and guard test (TASK-3917 r3)

r3 F1: the "/" prefix rule walked back to the previous whitespace for every
match, so "/@a" repeated was quadratic (240 KB took ~10 s, in the comment
write transaction). A forward scan now tracks the run start and the last
"://" once per call. F2: the reindex doc no longer says ONE query. F3: the
guard test legs are isolated. F4: callsReindex documents its fail-open cases.

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3

* fix(links): mention code-span check walks the spans once per call (TASK-3917 r4)

isInRanges scanned every code range for every match, so many inline code
spans followed by many mentions was quadratic (360 KB took ~1.1 s). A cursor
over the sorted, disjoint ranges advances with the increasing match position.
ExtractWikiLinks has the same shape and is left for its own item.

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3

* test(links): a row where the code-span cursor skips several spans at once (TASK-3917 r5)

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3

* docs(links): CJK letter after a mention is a documented limit (TASK-3917 r3 F5, ruling 6250d6ec)

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
2026-10-19 23:27:16 -04:00
xarmian e598bc4f64 feat(decision): effort, effort_medium and high_stakes on the attention set, staged; drain-paced backfill (TASK-3921) (#2236)
* feat(decision): effort, effort_medium and high_stakes on the attention set, staged; one-shot backfill (TASK-3921)

- attention set gains effort (Score), effort_medium (Choice) and high_stakes
  (Noul) in the same call; needs_human_decision's text is unchanged.
- QuestionSet.Unsurfaced names the staged keys: WorkspaceFlags and
  SurfacedDecisions skip them (no flag, no state read, no counter); the
  decisions list and endpoint still return every row.
- attention chips only render keys that have a label.
- store: per-set enqueue body extracted (owedDecisionJobTx), OweDecisionJobs
  (chunked, under the installed debounce) and ItemsMissingDecisionKey.
- Runner.BackfillEffort owes the attention set once for eligible items with a
  needs_human_decision answer and no effort answer, marker
  effort_decisions_backfilled written after every chunk; hooked at the end of
  reconfigureDecisionsLocked (boot and later admin enable); makes no provider call.
- eval harness: titlebody now sends the live set; RESULTS.md rows pending.
- docs/api.md and tests updated.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* test(decision-eval): keep titlebody as the day-73 control, add titlebody-live; record the TASK-3957 and TASK-3921 runs

- titlebody again sends the day-73 pair (needs_human_decision + blocked), so it
  stays comparable with its 09-22 row as the model-drift control.
- titlebody-live sends the live attention set on the same {title, body} state:
  the TASK-3921 gate (needs_human_decision AUC 0.936, within .02 of 0.939).
- Every run prints the question keys it sends, before -dry too, so a row's
  call shape is in its own log.
- RESULTS.md: rows for the one-question call (TASK-3957) and the
  four-question call (TASK-3921).

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* fix(decision): effort backfill lifecycle, staged keys off item reads, dry-run guard (TASK-3921; #r1 F1-F4)

- F1: startEffortBackfill checks stopped and does bg.Add under one
  decisionTick.mu hold; BackfillEffort/EffortBackfillCandidates take a ctx
  checked before each candidate read and each chunk; cancel returns ctx.Err()
  and never writes the marker. stopDecisionTick cancels the run.
- F2: reconfigure cancels a running backfill; only a non-nil runner starts a
  new one. A per-run generation replaces the atomic latch so a cancelled run
  cannot clobber its successor.
- F3: item show / --agent / MCP item get use Runner.ItemReadDecisions, which
  drops the set's Unsurfaced keys; the decisions endpoint stays raw. docs/api.md
  updated.
- F4: the file dry run refuses a path with a -wal/-shm sibling or under
  $HOME/.pad (refuseLiveDatabase, tested without the env var).

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* test(decision): a chunk write that fails in the store writes no marker (TASK-3921; mutant M6)

The two failure tests fail the per-chunk test hook, so the path where
OweDecisionJobs itself returns an error was never exercised: a mutant that
ignores that error and marks anyway survived. The new test breaks the
decision_jobs table from the hook so the error comes back through the store,
asserts no marker, then restores the table and re-runs to completion.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* refactor(decision): drop the pre-marker cancel check; a cancel after the last chunk may mark (TASK-3921; M5, lead ruling)

Mutant M5 (that check removed) survived every test, and could not do
otherwise: a cancel can land there only after every chunk was owed, and the
marker means every candidate was ENQUEUED, not answered, so marking then is
true. The check is removed rather than kept as a guard no instrument can
turn red. The doc comments on BackfillEffort, cancelEffortBackfill and
startEffortBackfill now say a cancel before the last chunk writes no
marker and one after it may.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* docs(api): a backfill chunk in flight when decisions are disabled may still commit (TASK-3921; r2 L1, lead ruling bdce2116)

docs/api.md said that with no provider "no job row is ever written". That is
false for one window: a disable that lands while the one-shot effort
backfill is inside a chunk's write lets that chunk commit, and its jobs wait
until a provider returns. Ruled docs-only. The disable test's comment now
says it covers a backfill parked before its first chunk, not that window.

Sweep for the claim (CONVE-23): grep of docs, internal, cmd, scripts and
skills (.go, .md) for "no job row", "job row is", "no job is", "no jobs
are written/owed/enqueued" and "never written/owed/enqueued". Two hits are
about this mechanism: api.md:72 (fixed) and the test comment (narrowed).
Unchanged, still true:
- store/decisions.go:27 is about the WRITE DOORS, which still enqueue
  nothing without a resolver.
- runner_test.go:238 is about the nil-provider runner, which never starts a
  backfill.
- effort_backfill_lifecycle_test.go:128 covers a run parked before its
  first chunk.
The other hits are unrelated mechanisms.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* fix(decision): pace the effort backfill on drain; dry run opens a copy; docs eligibility (TASK-3921; r3 M1, L2, L4)

- M1: the backfill owes one tick pass of items per chunk (20 default, wired
  from the tick limit) and owes chunk k+1 only once chunk k's own jobs have
  drained: none claimable now, by ClaimDecisionJobs' own predicate
  (new Store.CountClaimableDecisionJobs). Debounced jobs do not hold it.
  The wait polls at the tick interval, honours ctx, and a cancel writes
  no marker; the marker still follows the last chunk being owed.
- L2: TestEffortBackfillDryRunOnFile copies the named file under GOTMPDIR
  and opens only the copy.
- L4: docs/api.md states the real eligibility and the drain pacing.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* docs(decision): the Unsurfaced comment names ItemReadDecisions (TASK-3921; r4 L2)

The field's comment listed WorkspaceFlags and SurfacedDecisions as the
readers that skip staged keys, but not ItemReadDecisions, which item show
(and so --agent and MCP item get) has used since the r1 F3 fix. Comment only.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* test(decision): break the table before the LAST chunk so the store-failure test pins M6 (TASK-3921; lead ruling)

Since the drain pacing (51c545850), the store-failure test broke the
decision_jobs table before chunk 1 of 3. Under mutant M6 (a failed chunk
write ignored) the drain wait that follows read the same broken table and
failed, so no marker was written by a different mechanism and the test stayed
green: an end-state assertion another path also produced (CONVE-12). Breaking
the table before the last chunk leaves no drain wait after it (the marker does
not wait for the last chunk to drain), so only the chunk's own error check can
keep the marker unwritten.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
2026-10-19 13:05:05 -04:00
xarmian e432d0f5bc feat(decision): debounce decision re-asks with decision_jobs.not_before (TASK-3916) (#2230)
* feat(decision): debounce decision re-asks with decision_jobs.not_before (TASK-3916)

IDEA-3906 half B, unit B2.

- Migrations 150 (SQLite) and 120 (Postgres) add decision_jobs.not_before
  TEXT. A job is claimable only once not_before is NULL or past, in the one
  decisionJobClaimable predicate both the scan and the claim UPDATE use.
- The enqueue sets not_before to the item's latest evaluation for the set
  plus the set's window, or NULL when there is no window, no prior
  evaluation (a first evaluation is never delayed), or the window is past.
  The arithmetic is in Go on decisionTime TEXT, so both backends agree. On
  conflict COALESCE(old, new) keeps an existing not_before, so a stream of
  comments cannot push the re-ask back.
- A superseded completion RECOMPUTES not_before from the latest evaluation
  rather than setting now+window (ruling ae2e0a90): the mid-run write read
  the evaluations before this run's answers landed. A run that stored
  nothing moves nothing, so it never invents a delay.
- Runner.Install wires Store.SetDecisionDebounce: ReaskDebounce (1h) for the
  item-level sets, none for a PerComment set or an unregistered one.
- The dashboard's attention comment says the pending window can now last
  up to an hour (#2229 r3-D1, ruling ce3e9dc5).
- The tick-interval and release comments no longer promise a job is
  claimable at once: a debounced one waits for its window.
- docs/api.md and README updated.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* docs: a debounced burst costs at most one call an hour (TASK-3916; #2230 r1 codex Low)

* docs: the debounce is per question set (TASK-3916; #2230 r2 codex Low)

* fix(store): debounce helpers stay inside the app-fence boundary (TASK-3916; #2230 CI TestAppstoreBoundary)
2026-10-19 04:08:03 -04:00
xarmian 4f277f836a fix(apps): an app upload is charged against the plan's storage cap (BUG-3653 part B) (#2119)
* fix(apps): an app upload is charged against the plan's storage cap (BUG-3653)

App uploads were capped only per install (2,000 files, 1 GiB), so on
Pad Cloud an app could add bytes past the workspace owner's storage_bytes
limit that a human upload would be refused for.

- FenceSpec.StorageLimit makes BeginFenced take the owner's storage
  plan-limit lock before its install read, as the lock's ordering rule
  requires. FencedTx.CheckStorageLimit charges bytes under it and returns
  ErrAppStorageLimit, which carries no totals, like the item cap.
- Admission checks the cap at the declared size before any byte is read,
  so the common refusal writes no blob. The row insert charges it
  authoritatively, and an owner change mid-check is retried
  (ErrStorageOwnerMoved, now exported for this caller).
- The server answers 403 storage_limit_reached.
- appstore sets the flag from Options.PlanLimit, so self-hosted is unchanged.
- The boundary allow-list takes the read-only storage helpers and the
  advisory lock, each with what it writes.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* docs(apps): the fence's lock-order comments name the storage lock an app upload takes first (BUG-3653)

Codex round 1: two comments said the install FOR SHARE (fenced_tx.go) and
the seq lock (fenced_attachments.go) come first, which a StorageLimit
spec now precedes.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(apps): an app upload whose row never commits reclaims its blob (BUG-3652, BUG-3653)

With #2118 merged, the app door gets the same reclaim as the human
doors. PendingUpload.Close, when the row never committed (its insert was
refused, as by the storage cap after an admission another upload raced,
or the caller's re-check refused it before Insert), hands the canonical
blob to Options.Reclaimer while the hash guard is still held. The server
implements it with reclaimRefusedBlob on the FS backend, which keeps the
blob when another row or writer shares the hash.

Options.Reclaimer is an interface, not a func field: the appstore
boundary guard refuses function values in app code outright (rule 9),
and refuses interface calls only where an internal/store type could
answer them (rule 4). The server's implementation reads row existence
for the hash and deletes a blob file; it writes no row.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(apps): a pending upload attempts its blob reclaim once, cleared before the call (BUG-3652)

Codex round: the storage key was cleared after the reclaimer returned.
It is now captured and cleared first, so a second Close can never retry
the reclaim after the hash guard was released, whatever the first
attempt did.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-13 15:44:44 -04:00
xarmian e87ace01e0 feat(apps): app webhook events name the install that wrote them (TASK-3411, SPEC-6 U10d) (#1781)
* feat(apps): app webhook events name the install that wrote them (TASK-3411, SPEC-6 U10d)

The app-projection block (v2) gains actor_via_app: the install whose
fenced write caused the event. The emitters get AsInstall variants that
take it; the existing signatures pass "", so every human and agent path is
unchanged and writes none. The five fenced emit sites (item create and
update, comment create, update and delete) pass FencedTx's install.

The app DTO's envelope via_app is now the actor on every event, so an app
can suppress the echo of its own writes (the support portal patches
public_status and receives item.updated for it) while a person's edit to
the same item carries none. A v1 block, written before v2 and still
pending, keeps U10b's rule: via_app on create events only, from the
creator.

Census: human writes carry no actor_via_app (TestTask3389), fenced writes
carry their install across every retained type (TestTask3411).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* test(apps): appstore boundary allow-list for the AsInstall emitters (TASK-3411)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-04 21:36:33 -04:00
xarmian 37066f97be feat(apps): app attachment routes: item-scoped upload, metadata, and a streamed download re-admitted before its first byte (TASK-3401, SPEC-6 U6c) (#1776)
* feat(apps): app attachment routes: item-scoped upload, metadata and a streamed download re-admitted before its first byte (TASK-3401, SPEC-6 U6c)

* test(server): an app attachment read refuses a subject whose membership no longer reaches the item (TASK-3401, U6c)

* test(server): U6c tests reach the item re-check, the upload's edit right and the download's type decision (TASK-3401, U6c)

* test(server): a deletion before the first byte reaches the attachment re-check (TASK-3401, U6c)

* fix(apps): U6c codex r1: authorize before storage, a transport write deadline, the credential last in re-admission, and a live companion check in the fence (TASK-3401, U6c)

* fix(apps): U6c codex r2: a truncated upload is 400, a re-admission fault is 500 not 401, HEAD on the download, and unknown variants refused (TASK-3401, U6c)

* fix(apps): U6c codex r3: the upload re-admits the subject before its row (StageUpload/Insert, no store callback), and variant is null on originals (TASK-3401, U6c)

* fix(apps): U6c codex r4: a token-state or settings read failure during admission is a fault (500), not a denial (TASK-3401, U6c)

* fix(apps): U6c codex r5: a credential that dies mid-upload answers 401 invalid_token, a permission change 403 (TASK-3401, U6c)

* fix(apps): U6c codex r6: the served variant is re-checked at the gate, and a settings read fault at the first gate is a 500 (TASK-3401, U6c)

* test(server): a viewer's upload is refused before its body is read (TASK-3401, U6c)

* test(server): account for the app upload's raw body reader in the body-reader census (TASK-3401, U6c)
2026-10-04 15:14:27 -04:00
xarmian 5e2accd179 feat(apps): app write routes: items create/update, comments create/update/delete, the write rule, SSE and watch parity (TASK-3401, SPEC-6 U6b) (#1773)
* docs(store): the per-call users read in the bot ceiling is accepted; how to remove it if profiled (TASK-3401)

* feat(server): app write routes: items create/update, comments create/update/delete, the write rule ahead of the editor fast path, SSE and watch parity (TASK-3401, U6b)

* test(server): app write routes: census, read-token refusal, write rule, refusals, disable race, SSE/watch parity (TASK-3401, U6b)

* test(server): the write-rule test gives the bot a role and a companion control, so only the rule can refuse (TASK-3401, U6b)

* fix(apps): an app write's status notification comes from its own transaction, never the handler's earlier read (TASK-3401, U6b codex r1 P2)

* fix(apps): an app item write's response is built from its own transaction: slug, schema, via_app, creator and ETag read before commit, nothing read after (TASK-3401, U6b codex r2 P2 x2)

* fix(apps): app comment writes publish the item and actor display their own transaction read, not the handler's earlier read (TASK-3401, U6b codex r3 P1+P3)

* docs(server): name the second layer behind the app comment same-item check (TASK-3401, U6b)
2026-10-04 12:22:00 -04:00
xarmian 526001c199 feat(appstore): app attachment upload, read rule and caps behind FencedTx (TASK-3396, U7a) (#1768)
SPEC-6 U7a (DOC-3371 §4 Attachments, day-85 revisions): the store, blob
and appstore layers of app attachments. Constant-work blob writes
(FSStore.Stage/Commit/Abort), the upload hash guard moved into
attachments.InFlight and shared with the app store, FencedTx upload with
exact install caps recounted under the workspace seq lock plus an
in-process admission reservation, the app read rule, variant inheritance
of uploader and via_app, the AppAttachment DTO, and migration 116/090
(attachments.via_app). The HTTP route lands with U6 or a U7b on top.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-03 21:31:44 -04:00
xarmian 35e4843772 feat(appstore): app comment create, edit and delete inside FencedTx (TASK-3391) (#1767)
SPEC-6 U2b (DOC-3371 §4). comments.source accepts 'app' (SQLite 115
rebuild with rowids, indexes, FTS and NUL triggers preserved; PG 089
constraint swap). FencedTx gains CreateComment, UpdateComment and
DeleteComment under the workspace seq lock, with item binding before
authorship, author-only edits, an item-bound attachment reference check
that writes nothing, and a cross-item-thread refusal on delete. appstore
exposes them.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-03 20:36:50 -04:00
xarmian b572f0739c feat(auth): the bot principal for installed apps: users.kind, kind gates on every sign-in door and credential, people-only counts (TASK-3392) (#1766)
* feat(store): the bot principal: users.kind, CreateAppUserTx, kind gates on every credential mint and claim, people-only counts, install-gated app projection (TASK-3392)

* test(store): record users.kind in the NUL census baseline (store-written, two values) (TASK-3392)

* feat(server): kind gates at the sign-in doors, admin 404 and member 409 for a bot, apps array on the member list, reserved domain refused (TASK-3392)

* docs+web: the members apps array (TS type, CLAUDE.md); people-only admin member count test (TASK-3392)

* test(server): the U3 owner-webhook strip test plants an install (TASK-3392)

* test(server): forgot-password and resend refuse a bot before attempting a mint (TASK-3392)

* test(server): a usable base URL so the resend door would really mint (TASK-3392)

* test(server): the session doors' own kind checks, with the bot already resolved (TASK-3392)

* fix: stored reset/verification tokens, PAT rotation, grants and OAuth bearers refuse a bot (TASK-3392, codex r1)

* docs: SPEC-6 §11 Q2 is decided (separate Apps section, no seat); the switches stay pinned (TASK-3392)

* docs(server): name the two layers in front of CLI approve's kind check (TASK-3392)

* test(appstore): the projection's install gate joins the reviewed door helpers (TASK-3392, rebased on #1765)
2026-10-03 19:41:18 -04:00
xarmian a17e2c415e feat(appstore): app item create and field update inside the fence (TASK-3390) (#1765)
* feat(appstore): app item create and field update inside the fence (TASK-3390)

SPEC-6 U2a (DOC-3371 v12 §4).

store.FencedTx write methods:
- CreateItem:
  - the companion check, then the human create's workspace lock
  - the item cap under that lock, only when Options.PlanLimit is set (Pad
    Cloud), as the human create does; it refuses with ErrAppItemLimit and no
    count
  - a random-suffix slug, never a collision scan (a scan would reveal hidden
    slugs)
  - the row (source app, created_via_app and via_app = the install)
  - the initial version (via_app), status transition, item.created event and
    decision-job enqueue
  - wiki links confined to companion targets
  - an in-fence "created" activity
- UpdateItemFields:
  - the seq lock FIRST, then the companion and created-by-this-install
    checks, then the etag (HMAC over install, item and seq under a
    server-only key, compared in constant time)
  - a field merge, the status transition, update events and enqueue
  - an in-fence debounced "updated" activity
  - no content, title, relation, parent or attachment change is reachable

Activities are written on the fence's transaction (seq lock, then the
debounce lock, the order BUG-2777 permits). A wiki link to anything but a
live companion item stays broken.

appstore.CreateItem / UpdateItem validate against the companion schema:
- refused: undeclared, relation, computed, unique-scoped and reserved keys
- coerced and validated as the human path does, with defaults applied
- ANY pad-attachment: token is refused in any spelling, and
  [[workspace::REF]] is refused
- the create retries ONLY on the items slug conflict (IsItemSlugConflict),
  never on another unique violation

Writer identity: via_app joins the activity debounce key and the version
throttle comparison. The U3 projection block carries creator.via_app.

Migrations SQLite 113 / PG 087: via_app on items, comments, activities,
item_versions and item_links; created_via_app on items. FK to app_installs,
whose rows are never deleted.

Boundary checker: reviewed door helpers (store methods and package-level
functions), each with what it writes, audited by reading every body; only
createActivityQ, enqueueDecisionJobsTx, replaceWikiLinks and writeOutboxTx
write, all on the transaction they are handed. Named function-value entries
(the decision-set resolver, two in-body closures) are keyed by function; a
same-named closure elsewhere is still refused (fixture). stampAttachmentRefsTx
stays unreachable.

Tests:
- write census per mutation on both dialects
- every refusal captures nothing
- the item cap and its self-host exemption
- companion-only links
- interleaved app and human creates never share item_number or seq (red on
  Postgres without the lock)
- via_app identity in the debounce and the version throttle
- nine mutants, each fails its test

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(appstore): scoped link resolution, defaults under the rules, a required etag key (TASK-3390)

Codex round 1:
- P1: an app link was resolved over the whole workspace and confined
  afterwards, so a hidden item sharing a companion's title could win the
  match and leave the link broken: an existence signal. Resolution is now
  scoped (linkScope) to companion collections in every query, the
  number-only and collection-qualified fallbacks included, so a hidden item
  cannot influence the outcome. The human paths pass the zero scope and are
  unchanged.
- P2: schema defaults are applied after the key checks, so a default could
  land a relation, computed or uniqueness-scoped value, or a pad-attachment:
  token. Validation now runs against a schema with those defaults removed,
  and the effective fields are re-checked for tokens.
- P2: an empty or short etag key made etags forgeable offline. ETag and
  UpdateItem now refuse with ErrNoETagKey unless the key is at least 32
  bytes.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-03 18:09:33 -04:00
xarmian 85c49cf2fd feat(appstore): FencedTx, the appstore skeleton, and the boundary and write-capture guards (TASK-3388) (#1764)
* feat(appstore): FencedTx, the appstore skeleton, and the boundary and write-capture guards (TASK-3388)

SPEC-6 U1 (DOC-3371 v12 §4): the door every app write will go through, and
the two tests that guard it. No app mutations or routes yet; U2 adds them
under these guards.

- store.FencedTx:
  - BeginFenced's first statement reads app_installs (auth_epoch, state,
    workspace), FOR SHARE on Postgres, and refuses unless the admitted epoch
    is current, the install is active and the workspace matches.
  - There is no raw Exec and no free-form query. The lock primitives
    (LockWorkspaceSeq, NextItemNumber, NextSeq) take the human item path's
    own workspace lock.
  - Companion guards: requireCompanionCollection, and requireCompanionItem
    for derived rows.
  - Every field is unexported, and a zero value refuses every method.
- internal/appstore: holds *store.Store concretely; withFence runs one
  mutation in one FencedTx; CheckFence is the live path until U2.
- The boundary test (go/packages + go/types) walks every module function
  the package can reach. It fails on:
  - a *store.Store method off a reviewed allow-list (stronger than the
    spec's deny-list)
  - database/sql calls outside the door
  - Store.db or (*Store).DB
  - an interface call or declaration an internal/store type answers
  - reflect or unsafe
  - the ordinary attachments Put

  One fixture per rule under testdata is asserted to be reported.
- The reverse test: no package outside internal/store and internal/appstore
  references BeginFenced.
- storetest.CaptureWrites:
  - SQLite: modernc's pre-update hook on a single connection, with a
    sentinel write proving the hook is still on it.
  - Postgres: statement-level audit triggers installed by the harness.

  The harness is proven red on a human create (items plus FTS5 shadow
  tables) and on a test-only trigger write at depth > 0, and silent on a
  read. The blind inventory is asserted exactly: items_fts, comments_fts,
  documents_fts and sqlite_sequence.
- guardConn.Unwrap exposes the driver connection to the harness.
- Migration: app_installs (SQLite 112 / PG 086); its columns are added to
  the NUL baseline, since no writer exists before U8, which must validate
  origin.

Moved to U2, where they are first used: DebounceActivity, CheckItemCap and
the via_app columns.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(appstore): close the boundary checker's gaps and the harness's coverage holes (TASK-3388)

Codex round 1. Each gap now has a testdata fixture asserted to be reported,
and the six P1 fixtures were checked against the round-0 checker:
- package-var: package-level var initializers and init functions are walked
  for the root and every module package it brings in that the human store's
  own closure does not
- generic: instantiated generic methods are normalized through Origin()
- embedded-sql: rules check a method's DECLARING receiver, so a method
  promoted through an embedded *sql.DB or *store.Store is caught at the call
- func-field: a call through a function value is refused (dynamic-call)
  unless it is a parameter of an unexported root function that every call
  site passes a literal or a named function
- linkname: a go:linkname directive or an unsafe import in a walked package
  is refused
- whole module: the walk covers the whole module, not only internal/
- embedded-store: the old checker caught this only by walking into
  DeleteItem's body; it is now caught at the call

Against the round-0 checker, all six P1 fixtures fail (pkgvar, helperinit,
generic, embeddedsql, funcfield, linkname); the extra embeddedstore fixture
passed there, for the reason above.

Harness:
- SQLite: PRAGMA data_version, read on the hooked connection at the start
  and end, refuses a capture during which another connection committed. A
  replaced connection is refused at the sentinel. Both branches are tested;
  the data_version test passes silently without the check.
- Postgres: triggers are ensured at every capture, so tables created later
  are covered; a table created during a capture is refused; captures are
  serialized per database.
- The Postgres lock tests wait until pg_stat_activity shows a backend
  waiting on a Lock, instead of treating 500ms as proof of blocking.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(appstore): refuse every function value instead of verifying callbacks; working-trigger check (TASK-3388)

Codex round 2 found five P1s in the checker and one P2 in the harness.

Four of the P1s were bypasses of one mechanism: verifying where a callback
parameter came from. They were reassignment, aliasing through a pointer, a
method expression shifting the argument index, and interface dispatch past
the call-site census. This is a guard, not a proof, so the mechanism is
removed rather than patched again:
- withFence(fn) is gone; app mutations open and commit their FencedTx
  directly.
- Every call through a function value is refused (dynamic-call).
- Any use of a variable, parameter or field of function type is refused
  (func-value). This also closes the fifth P1: an exported function-typed
  package var in the store's own import closure, run through
  sync.Once.Do, whose initializer the walk excludes.

Interface checks now judge the INSTANTIATED receiver, since Mutator[string]
can be implemented where Mutator[T] is not; Origin() is used only for
indexing.

Harness P2: a pad_write_audit_t trigger counts only when it is enabled,
statement-level, AFTER, on INSERT/UPDATE/DELETE, and calls
pad_write_audit_fn. Otherwise it is dropped and recreated.

Fixtures genericiface, callbackparam, methodexprcallback and oncevar each
pass against the round-1 checker and are caught now. A Postgres test
disables the trigger and expects the next capture to see the write.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(appstore): refuse interface dispatch on unbound type arguments (TASK-3388)

Codex round 3: a generic helper run[T](m Mutator[T]) is walked once, with T
unbound, so Mutator[T] never matched *store.Store and the call went
unflagged. An interface call whose receiver is, or is instantiated with, a
type parameter is now refused, rather than propagating type arguments
through the walk. Fixture genericifacebody passes the round-2 checker and is
caught now.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(appstore): no interface dispatch inside a generic body (TASK-3388)

Codex round 4 found two more ways a type parameter hides from the
round-3 check:
- an anonymous interface constraint (or a generic alias)
- a type parameter nested inside a func, slice, map or alias type argument

Chasing type parameters through every type form is open-ended, so the class
is closed outright: a generic function body, walked once with its parameters
unbound, may make no interface method call except on error. A non-generic
function cannot hold an unbound type parameter, so the ordinary
implemented-by-store check stays exact there. Fixtures genericconstraint and
genericnested both pass the round-3 checker and are caught now.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-03 16:02:18 -04:00