Skip to content

GO-7383 API v2: a JSON HTTP API for agents over AnyBlock JSON - #3242

Closed
requilence wants to merge 262 commits into
go-7383-anyblockjsonfrom
go-7383-apiv2-phase0
Closed

GO-7383 API v2: a JSON HTTP API for agents over AnyBlock JSON#3242
requilence wants to merge 262 commits into
go-7383-anyblockjsonfrom
go-7383-apiv2-phase0

Conversation

@requilence

Copy link
Copy Markdown
Contributor

Stacked on #3241 (AnyBlock JSON). Review that one first — this branch is the consumer; the diff shown here includes its base until #3241 merges.

A local JSON HTTP API designed for LLM agents, serving and accepting AnyBlock JSON documents. It shares nothing with v1 at the type level — core/api/v2 is its own package tree with its own OpenAPI document — so v1 is unchanged and its generated docs are byte-identical throughout.

Specs (read in this order)

core/api/APIV2.md the API spec: conventions C1–C13, the surface, and §8.x as-built notes recording what each round actually shipped and why
core/api/APIV2_PLAN.md the plan of record — what is done, what is staged, what is deferred, and the decisions still owed to a human
core/api/APIV2_TOKENS.md the measured token review that drives most design choices
core/api/APIV2_SURFACES.md which v1 surfaces get reimplemented vs reused, and why
pkg/lib/anyblockjson/ADDRESSING.md the identifier layer — how types, properties and options are addressed
core/api/APIV2_OBJECT_DELETE.md creator provenance and own-output-only deletion
docs/DerivedDeleteConsistency.md a plan for a store inconsistency this work uncovered (not implemented here)

What's built

Read, create, edit (a closed, id-addressed op set), query with a compact filter DSL, chats, the space periphery, the view family, scoped API keys with a fail-closed route registry, and the identity layer (one slug vocabulary, both directions, derived from the bundle).

Two agent-facing surfaces sit on top: a task-tool wrapper (12 tools, CLI verbs and an MCP stdio server, two model tiers) and SKILL.md guides for direct HTTP use.

cmd/apiv2eval is an evaluation harness that runs real local models through the real API. It measured most of what shaped this branch — and found defects three rounds of code review had missed.

Changes outside core/api

These are the ones worth calling out, because they touch shared or shipped code:

  • pb.Change.integrationName (field 10) — the raw, user-chosen name of the paired API key, stamped on an object's creating change and used to enforce own-output-only deletion. Immutable for the same reason createdDate is: it rides a signed, CID-addressed, append-only change. Stored raw and compared exactly — normalization was tried and rejected, because it made visibly different app names into one principal. Additive on the wire; old clients ignore an unknown field, and nothing re-marshals a change in a real tree.
  • core/block/editor/smartblock, state, source — carry that stamp, per-apply only: a later local edit must not inherit it.
  • core/application/sessions.go, core/session, core/wallet — API-key issuance now requires a non-empty app name and bounds it (there was no bound anywhere before).
  • core/block/object/objectcreator — mint-time uniqueness for apiObjectKey, checking stored slugs, stored keys and the bundled-derived vocabulary.
  • space/internal/components/migration/apiobjectkey — backfills apiObjectKey where it is absent.
  • pkg/lib/bundle/apislug.go — the bundled key ↔ slug table, both directions, built from the bundle (a case transform does not round-trip; this is a table on purpose).
  • core/block/detailservice/set_details.go — archive success is judged over the requested ids, not over a cascade that may have succeeded while the target was refused.
  • pkg/lib/localstore/objectstore/spaceindex/relations.goListRelationOptions excludes uninstalled options.
  • util/anonymize — covers the new field, so an app name cannot ride a debug export verbatim.
  • core/api/service/cache_manager.go (v1) — indexes a type under its derived key as well as its served key. Without this, an integration holding a custom type key breaks after a restart once a slug is stamped.

Regenerating

  • OpenAPI: make openapi — two documents, core/api/docs/{v1,v2}. Both are committed; v1 must stay byte-identical.
  • Protos: make protos-go after touching pb/protos.
  • Mocks: make test-deps.

Not in this branch

Locator slices 2.1c/2.1d, the ?mode= read collapse, Phase 8 surface completion, and the format-level pin work — all staged in APIV2_PLAN.md and intended for follow-up branches.

Three defects in develop were found while auditing delete semantics and filed rather than fixed here: GO-7443, GO-7444, GO-7445.

…2 notes

A cached ?dry_run=true result must never replay as its later real twin —
the (space, key) hash now covers body + raw query, so same key with a
different query is a 409 idempotency_conflict (C8 read as different
request). APIV2.md gains §8.1 recording the Phase-2 implementation
decisions as built: the snapshot create path and its rejected alternatives,
the explicit create-vs-reject policy, the type/set/shortcut specifics, and
the SPEC §2a recommendedLayout example-vs-export discrepancy flagged as a
spec bug.
…documents

FillRecommendedRelations detects already-filled lists by the FIRST entry of
recommendedRelations; a type document whose typeProperties are all
featured/hidden/file left the regular section empty and sent the RPC down
the layout-defaults path, silently clobbering the document's featured list.
CreateType now seeds an empty regular section with the system default
sidebar properties (createdDate, creator, links) resolved through the
create-missing resolver, keeping the RPC on its verbatim path. Plus adapter
helper tests (snapshotRootId, bundledIdsToInstall).
Brings 26 base commits (format evolution) under the v2 API work.

Conflict resolutions, both in pkg/lib/anyblockjson:
- json.go: kept our exported FormatName/FormatByName alongside base's
  collapsed text-format vocabulary. FormatName now delegates to base's
  formatName helper so it applies the same shorttext->"text" fold; calling
  formatNames.name directly would have returned "" for shorttext.
- typeproperties.go: our exported TypeProperty (+ the jsonTypeProperty
  alias, RecommendedList, BuildRecommendedLists) now carries base's new
  Options and ObjectTypes fields, and BuildRecommendedLists threads them
  into PropertyDefinition so a property minted through the PATCH-type
  surface gets the same shape import gives it.

Adaptation to the collapsed text formats (base dropped "shortText" from the
format vocabulary and the schema enum): the synthesized set document no
longer declares name as shortText — every dataview property now resolves
through the one text name — and the two agent-facing format lists (the
POST /properties served schema and its error hint) stop advertising a
format the schema rejects.

Verified in the merged tree: our dataview block id matches
template.DataviewBlockId, and the create surface already accepted
recommendedLayout as a name, which is what base standardized on.

go build ./... clean; core/api and anyblockjson suites green; gofmt clean.
API v2 Phase 3 pre-checks edit ops (leaf containment, replaceText targets)
before the full document validation; export the package's leaf and
text-bearing type sets so the wiring shares one source of truth.
MutateObject(ctx, space, object, build) locks the object, hands build the
same consistent read the Phase-1 reader produces, diff-applies the snapshot
build returns via state.NewDocFromSnapshot + history.ResetToVersion (the
proven import updateExistingObject shape, incl. the bundled-revision
guard), and reports the post-apply heads for the new etag. The shared
locked-read helper is factored out of the read adapter.
PATCH /v2/spaces/{id}/objects/{id} applies the closed, id-addressed op set
(setProperties, updateBlock, replaceBlock, replaceSubtree, insertBlocks,
moveBlock, deleteBlock, replaceText, setCell, addItems, removeItems)
atomically: marshal the live state to its flat document, mutate the
document, validate it wholesale (R5 = SPEC §12 V1-V5), Unmarshal with the
Phase-2 create-missing resolvers, one diff-apply through ObjectMutator.
Block refs resolve by full id or unique suffix; payload indents are
relative (R3); every payload block id rides createdBlocks keyed by payload
position; If-Match is advisory (C7); dry_run computes without committing.
PATCH refuses objects whose marshal reports loss warnings (C11).

PUT replaces the whole document through the same pipeline (etag/warnings
stripped so a GET body round-trips; envelope id pinned; absent type keeps
the live one; canUpdateObject exclusions mirrored). diffStats diff the
canonical before/after documents - the accidental-full-rewrite signal.
PATCH/PUT object routes (write-rate-limited; concurrency safety is the
If-Match header - the idempotency middleware stays POST-only) and
GET /v2/schemas/ops/{op}: one tiny C13-strict schema plus a single-op
minimal example per op, wired into the §5 index as an ops list.
Record the decisions as built: document-level op pipeline over the
reset-to-version apply, the ObjectMutator port shape, R5 via the format's
own Validate, the C11 PATCH guard, diffStats semantics, R3 indent rules,
per-op decisions, and PUT details.
The 4-lens review (core/api/APIV2_PHASE3_CODEREVIEW.md) found that resetting
the live object to an AnyBlock snapshot turns everything the format
deliberately drops into a destructive CRDT change:

A1: the snapshot carries no RelationLinks, so the diff emitted RelationRemove
for every CUSTOM relation key — which deletes the detail value on replay
(the GO-7217 class; ResetToVersion repairs bundled keys only). Custom
property values survived locally but vanished on other devices.

A2: resolvedLayout is a stripped local detail, so resolveLayout saw
unset->recommended as a change and ran the note conversion, which moved an
unnamed page's first paragraph into the title and unlinked it.

A3: structural blocks (header/title/description/featuredRelations) are
dropped by the format (SPEC §7); the diff deleted them on every edit and
featuredRelations only returns on a full source rebuild, so the featured row
vanished for open clients.

A4: the apply runs with NoRestrictions and only 4 sbTypes were excluded, so
the API could rewrite objects the editor forbids (workspace, archive,
widgets, a set's dataview) and reach type objects while bypassing the
/v2/types guards.

Fixes, all in the API adapter (core smartblock/apply semantics untouched):
preserveEditorOwnedState carries live RelationLinks, resolvedLayout, the
structural block subtree (restored leading the root) and extra object type
keys into the reset state; checkObjectEditable enforces the object's own
Blocks/Details restrictions before applying. Multi-type objects no longer
lose ObjectTypes[1..].

Tests: the adapter had zero coverage — added unit tests for each finding
(relation links, resolvedLayout, structural restore + ordering, extra types,
edit preserved, no-structural-blocks case, restriction refusals, the
structural-block predicate).

Still open from the review: A5 (ResetToVersion forces DoSnapshot -> a full
snapshot change per edit) needs a scoped change to the shared
smartblock.ResetToVersion used by import/history/block-service, so it is
filed rather than bundled here; A6 and the Tier-B seams follow.
Export the conversion machinery at fragment level for the API v2 edit
path: UnmarshalBlocks (flat run -> subtree with topIds), UnmarshalBlock
(single block with forced id, table internals included),
UnmarshalPropertyValue (import twin of MarshalPropertyValue),
MarshalBlockSubtree (subtree -> flat JSON run), and the inline codec
(ParseInlineText/RenderInlineText).

Fragment runs are validated by wrapping them in a minimal synthetic page
document and running the existing document validation, so V1 monotonicity
and the $5 per-type shape checks apply unchanged. Two fragment-specific
guards: structural block types (title/description/featuredProperties) are
rejected explicitly instead of silently absorbed, and no primary-dataview
pinning happens on fragments.
…nary Apply

Split apicore.ObjectMutator into MutateObject (PATCH: the adapter hands
the callback an ObjectEdit carrying a child state of the live doc and
commits it with ONE plain sb.Apply — per-block restriction checks, undo
recording, hooks/events and the minimal id-matched change diff ride the
normal editor path) and ResetObject (PUT: the previous reset-to-version
machinery with preserveEditorOwnedState, kept until its own rework).

The bundled-revision guard stays on the PATCH path (untouched
revision/sourceObject are now simply inherited by the child state, so it
only fires on an actual downgrade). Adapter tests cover the commit path,
error rollback, restriction refusal and the revision guard.
Rewrite the Phase-3 PATCH pipeline: ops now mutate a child state of the
live object (v2_stateops.go) instead of splicing the flat JSON document
and resetting the object to the reimported snapshot. The flat document
survives only as the read-only view the ops address blocks through
(refs, unique suffixes, indent arithmetic, error texts — unchanged
agent-facing contract) and as the diffStats input; payload blocks are
interpreted through the anyblockjson fragment API with the same
validation a whole document gets.

Op -> state mapping: setProperties = SetDetail/RemoveDetail +
AddRelationLinks (the A1 fix in miniature); updateBlock/replaceBlock =
fragment re-import of the one block with its forced id, live children
kept; replaceSubtree/insertBlocks = fragment run + InsertTo splice;
moveBlock = Unlink + InsertTo; deleteBlock = Unlink; replaceText =
find/replace on the document text + ParseInlineText back to marks;
setCell = re-import of the one table block; add/removeItems =
GetStoreSlice/UpdateStoreSlice.

Validation without the whole-document pass: fragment runs validate in a
synthetic document (V1 + the §5 shape checks), duplicate ids are checked
explicitly against the state with op-shaped paths, the op-level
pre-checks are unchanged, and a debug-flag-gated read-only
marshal+Validate safety net (ANYTYPE_API_V2_VALIDATE_EDITS=1) logs any
post-op issue. Create-missing option resolution moved BEFORE the object
lock (review B6/A6) — no create-RPC ever runs while holding it.

The edit tests port to the new pipeline (same ops, same error texts,
same documents — asserted via the resulting state); PUT stays on the
reset pipeline through ResetObject.
…uilt

Record the redecision superseding v0.3.3's document-level apply: ops are
operations on a child state, committed with one ordinary Apply; the
mutation port split (MutateObject/ResetObject); the exact op->state
mapping; how R5 validity is preserved without the whole-document
Validate; create-missing before the lock; why diffStats stay the
canonical document diff (dry-run parity); and what is now untouched by
construction.
The 4-lens redesign review (core/api/APIV2_REDESIGN_CODEREVIEW.md) confirmed
the state-ops thesis but found three things strictly worse than the pipeline
it replaced.

A1: create-missing prewarm ran before ANY precondition — before the object
was known to exist, before If-Match, before restrictions. A PATCH to a
nonexistent object returned 404 and still permanently created every option
the batch named, and because prewarm resolves all ops up front, one rejected
request could mint the whole batch's options. PatchObject now reads the
object and checks preconditions FIRST, prewarms only once they pass, then
takes the lock. (The dry-run branch reuses that read instead of re-reading.)

A2: the per-op view rebuild was a DoS regression. Each op marshaled the whole
document AND built a fresh storeresolver whose caches start empty, re-running
ListAllRelations/ListRelationOptions per op, under the object lock, with no
op cap; begin()'s document was discarded so even a 1-op PATCH marshaled three
times. Now: one resolver reused across the whole PATCH, the view seeded from
begin()'s document, the after-document reused from the view when still valid,
a 512-op cap, and a ctx check per op so an abandoned request stops holding
the lock.

A3: every table op re-minted both layout wrappers (the format does not carry
them, so the importer generated fresh ids), turning a cell edit into
'replace both wrappers, re-parent every row and column' — and two devices
editing different cells merged into a table with duplicated rows/columns,
while diffStats reported BlocksChanged:1. replaceLive now pins the live
wrapper ids onto the re-imported table.

Tests: wrapper ids survive a setCell; a stale If-Match is refused before the
mutator with no option created; the op cap is enforced path-addressed. The
stale-If-Match test no longer expects MutateObject — that it is never reached
is the fix.
The state-ops redesign replaced the whole-document Validate with per-fragment
validation, which cannot see invariants that span the spliced result. The
4-lens review found two of those silently unenforced.

B3: the post-op document validation is ON by default again (it was an
env-gated log). It costs nothing — the after-document is already marshaled
for diffStats — and it is the cheap backstop for both findings below. The
escape hatch inverts to ANYTYPE_API_V2_SKIP_EDIT_VALIDATE=1, for debugging a
suspected false rejection.

B1: V3 row->column containment was enforced by nobody. resolveTarget checks
only leaf types (a row is correctly not a leaf), and checkFlatRun now sees an
isolated fragment. Single ops (insertBlocks inside a row, moveBlock inside a
row, replaceSubtree over a column) returned 200 and produced documents that
fail anyblockjson.Validate — so the object's own GET body was no longer
PUT-able, violating R5. The restored post-op validate rejects the whole PATCH.

B2: the absolute depth bound. Fragment validation is run-RELATIVE, so a deep
run passes on its own and can push the document past the bound when spliced.
Worse, marshalDoc installed a no-op warning sink, which makes the exporter
CLAMP instead of failing: the view then reported wrong depths to later ops in
the same batch (deleteBlock saw descendants == 0, skipped the recursive guard
and dropped a whole subtree), and the clamped after-document validated clean.
marshalDoc now treats a degradation warning as an error on the internal
paths, so the PATCH is rejected instead of committing a corrupted edit.

Tests: the row->column violation is refused while a legal insert into a
column still passes (no false rejection); an over-deep insert is refused
rather than clamped.
C1: R5 payload-validation errors lost their ops[i] prefix. Fragment
validation produces run-relative paths, so in a multi-op batch every payload
problem surfaced as '/blocks/0/type' — the repair loop could not tell which
op failed. invalidFragmentError now rebases those paths onto the op that
carried the payload ('ops[2].blocks[3].text'), for insertBlocks,
replaceSubtree, updateBlock and replaceBlock.

C2: dry_run reported every would-be-created option twice, because the
pre-lock prewarm and the in-lock op both resolve the same name and the
dry-run branch recorded without memoizing. The real run reported one. Since
dry_run exists to preview exactly that, it now dedupes via a dryReported set
(a separate set, not the created-id map, so an empty id can never be mistaken
for a resolved one).

C3: the object-level restriction verdict now rides ObjectRead, captured under
the same locked read. dry_run previously skipped checkObjectEditable
entirely, so it returned 200 for an object the real PATCH refuses with a
restriction error; both paths now reach the same verdict, and the real path
refuses before prewarm rather than inside the lock.

Tests: a dry run previews one option, not two; a restricted object is refused
on the dry run as well.

Remaining from the review: C4 (updateBlock's merge round-trips the touched
block through its exported form, so non-format fields on that one block are
rebuilt) and the Tier-D/E items, incl. E'8's change-set assertion.
…veBlock

Omitting all of after/before/inside now appends at the end of the
document root — the ops-path into an empty object (SPEC §7 keeps
title/description out of the document, so a fresh object had zero
addressable anchors and PUT was the only way to give it content).
Payload indents stay relative: at root, indent 0 = document top level.
position still requires inside. The multi-target error is reworded to
'at most one of after, before, inside is allowed' since zero is legal
now. §8.3 v0.3.5 notes added.
Agents auto-retry on timeout, and PATCH is where a blind retry does
damage: a retried successful insertBlocks duplicates blocks, a retried
deleteBlock 404s misleadingly. The C8 store, body+query hash,
reservation and replay all existed — this wires the middleware onto the
object PATCH/PUT routes and the types/properties PATCH routes, and lets
it act on POST/PATCH/PUT. GETs with a key pass through untouched.
… in setProperties

Appending one tag to a long multiSelect required read → whole-array
rewrite → write. add appends without duplicating; remove deletes
matching entries and is a no-op when absent. Only list-shaped formats
(select, multiSelect, objects, files); scalar keys are rejected
path-addressed naming the format. add shares set's create-missing
option-name resolution, incl. the pre-lock prewarm; remove resolves
read-only so it never mints the option it names. A key may appear in
at most one of set/unset/add/remove per op.
…ing, pre-release)

Four routes to changing a block's text was the surface's largest
disambiguation load, and replaceBlock's silent text-wipe (a checkbox
toggle losing the text) was the documented small-model trap. updateBlock
{id, set} with merge-and-null-clears expresses everything replaceBlock
did except the wipe. The op set is 10 ops; an agent sending replaceBlock
gets the unknown-op error with a hint naming updateBlock's semantics.
replaceBlock tests migrated to updateBlock; the wipe-only assertions
retired.
A 3-lens review of the v0.3.5 modification-surface changes found one major
and two contained defects; two lenses reached the major independently.

Idempotency replay identity ignored method and path. The hash covered body +
query and the store key was (space, key) — but PATCH/PUT carry the target
object in the PATH, which POST never did. Two byte-identical edits to
different objects under one reused key replayed the FIRST object's 2xx, with
its etag, leaving the second object unedited and no error to repair from —
the one failure class an agent cannot detect. Agents commonly derive keys
from request content, and 'apply the same ops to N objects' produces
identical bodies, so this was reachable in normal use. Method and path now
join the hash, turning the collision into the existing 409
idempotency_conflict.

Prewarm minted options for PATCHes that cannot succeed: it scanned every add
value, including scalars (always rejected by the apply path) and keys also
claimed by set (rejected as a cross-field conflict), leaving orphan options
in the space behind a guaranteed 400. It now skips both.

add on a single-select silently produced a two-valued select, since status
is list-shaped in the value encoding but holds one value. It is refused with
a steer to set; add on an EMPTY select stays legal, which the paired test
pins.

Tests: cross-object and cross-method replay refusal, PUT replay (the method
switch covered PUT but nothing exercised it), and a router-level test
asserting the middleware is actually registered on each edit route — the
E'8 class, where reverting router.go alone left every other test green.
…0-3 surface

Apply the consolidated findings of three read-only reviews (phase4, phase5,
crosscut) so Phases 0-3 read as fact and Phases 4-5 are buildable against
the current primitives.

- Phase 4 replanned: collections read path, sorts/C10 pagination, single-form
  primary example, explicit validation/resolution rules (key scope, system-key
  allowlist, read-only option resolution, per-space global semantics,
  empty-date warning, type pseudo-key), placeholder substitution for stored
  views, search declared a read (C8/C9 exempt), build-vs-reuse inventory.
- Phase 5 / §7 aligned with the shipped op set: R9 create-missing option
  names, add/remove on set_properties, check_item over updateBlock, markdown
  decided as an insertBlocks payload alternative, reference/editing channel
  caveats (full-read relabeling, D'1 markup source), hard dependency order,
  handle-state story, create-with-markdown caveats.
- §3/§4 refreshed: built items moved out of the build list; B1/B2 reworded
  to steering decisions (replaceText/setCell and both filter forms ship).
- §5/§6: nine shipped kinds + search kind + filter-grammar discovery slot;
  rollout records the ungated as-built posture; archive marked outstanding.
- §8.x corrected to the shipped code: idempotency hash covers
  method/path/query/body, etag 8-char prefix accepted, R5 whole-document net
  ON by default (B'3), A'1/C'3 appended to §8.3, DELETE /objects re-marked
  [build].
- SPEC.md bumped to v0.7: §6.2.1 scope split - the filter grammar + parser
  ship now as pkg/lib/anyblockjson/filterstring for the API surface; only the
  document view field 'filter' stays reserved post-v1. §12/§13 updated.
…ed filter/sort fragment codec

filterstring (SPEC §6.2.1, scope split v0.7): recursive-descent parser for
the compact filter grammar — AND/OR precedence (AND binds tighter),
parentheses, the full condition vocabulary incl. set literals (exactIn),
HAS ALL, IS [NOT] EMPTY, EXISTS, date-preset functions with the counting
pair carrying its operand, RFC 3339 → unix conversion on date-formatted
properties. Emits the §6.2 structured filters array so both request forms
land on one internal tree. Every error is offset-addressed (*Error: byte
offset + offending token) with did-you-mean against wired-in property keys
and option names (read-only — a query never creates options). The EBNF the
parser pins ships as filterstring.EBNF (+ Examples) for the discovery
surface and the Phase-5 GBNF conversion.

UnmarshalFilters/UnmarshalSorts: the fragment-granularity §6.2 codec the
two request forms converge on — enum vocabulary validation with
/filters/i//sorts/i paths, the counting-preset operand and placeholder
format rules via the document path's checkDateFilters, and the unguarded
date-comparison warning riding Options.OnWarning (C11).
POST /v2/spaces/{spaceId}/search + POST /v2/search (global), and the
sets/collections read path (GET sets/{id}/objects|views,
collections/{id}/objects|views).

Search: both filter forms — the compact string (filterstring) and the
structured §6.2 array — land on one internal tree via
anyblockjson.UnmarshalFilters and one direct database.Query; both supplied
is a 400 ambiguous_input. Sorts take any property key (v1's 4-value enum
is gone); pagination is the C10 query params, a body limit is rejected by
the strict schema with steering. Search is a read: no idempotency
middleware on the routes (asserted by test), dry_run ignored. The Phase-4
rules as specced: key scope from the type's recommended set or the
space's keys, the system-key allowlist, READ-ONLY option resolution with
did-you-mean (a query never creates the option it names), per-space
global resolution with skip-warnings and honest totals (sum of per-space
store counts — never total=len(fetched)), the unguarded-date-comparison
warning riding the C6 channel, and type as a filter pseudo-key composing
with the scalar top-level type by AND. Global rows carry spaceId.

Sets/collections: one implementation branching on layout; a wrong-layout
target 400s naming the other route. Sets execute their setOf source
(types → type In, relations → NotEmpty, OR-combined) — the direct
store-query path, NOT v1's racy shared-subId subscribe hack. Collections
read the store slice in curated order (honest totals; dangling members
drop out). ?view= resolves by id or unique suffix and substitutes the
§6.2 dynamic placeholders server-side: _filter_template_2_ → the caller's
participant id (V2Deps.AccountId, wired from the account component),
_filter_template_1_ → the host object; anything else drops the leaf and
warns — never v1's silent empty result. Views read back as raw §6.2 view
objects with option names.

C5 row building is extracted into a shared objectRowBuilder;
V2ListResponse gains a warnings channel.
… with the system-key allowlist

The compact filter string on POST /sets now parses through
anyblockjson/filterstring against the queried type's reference set and
lands as the structured array in the set's initial dataview (SPEC §6.2.1:
the document field filter stays reserved — export keeps writing filters).
Option names are deliberately NOT parse-validated on this path: a set
create is a write, where select option names create-missing (R9/§8.1) —
unlike the read-only query path. Parse errors surface offset-addressed at
/filter with did-you-mean.

validateViewKeys (the R9 sets rule) now admits the Phase-4 system-key
allowlist — createdDate, lastModifiedDate, creator, lastOpenedDate — keys
in no type's recommended lists that back bread-and-butter queries
(rule 2).
…ammar on the filters kind

GET /v2/schemas/search serves the strict (C13) search request schema with
the spec's single-filter worked example (asserted parseable by test). The
compact filter-string grammar gets its discovery slot ON the existing
filters kind — one concept, one slot (C2): that kind's response now
carries the structured-array schema AND the EBNF + examples pinned by the
filterstring parser (every served example asserted parseable) — the same
artifact the Phase-5 GBNF conversion consumes.
…ild items to the Built ledger

Records the implementation decisions Phase 4 settled: the parser's
case-insensitive keywords and format-resolver-driven RFC 3339→unix
conversion, the one-tree convergence through UnmarshalFilters, the
effective sort list (explicit sorts primary under full-text with a
relevance tiebreak), fulltext totals from the materialized
candidate-bounded set, the global merge comparator's collation
approximation, spaceId on global rows, the read-only-vs-create-missing
option split between the query and sets-create paths, empty-setOf as an
explicit 400, collection store-slice ordering, placeholder degradation
semantics, and the AccountId plumbing. §2/§3 markers updated so nothing
built is re-budgeted; only the [B3] rows encoder stays gated.
…runes

Phase-4 review fixes (parser lens):
- input capped at 4096 bytes and group nesting at 32 — a paren-bomb body
  used to overflow the goroutine stack, a runtime FATAL that killed the
  whole process (gin.Recovery cannot catch it)
- the lexer decodes full runes: non-ASCII property keys (café, дата) are
  identifiers now, and 'unexpected character' names the rune the caller
  wrote instead of a stray continuation byte
- date presets are rejected on the conditions the engine would silently
  drop (notEqual and everything non-ordering), addressed at the preset
  name token instead of the closing paren
- counting presets bound their day count to [0, 36500]
- unterminated-string errors echo at most 32 runes, never the rest of
  the input
- steering hints: single quotes -> double-quote example; reserved-word
  keys and known keys the syntax cannot spell -> the structured filters
  array
- EBNF gains identifier/number productions and the case-insensitivity
  note; ParseDate is exported for the structured-form date check;
  SPEC §6.2.1 records the format-driven date mapping and the parser's
  interpretation calls
…-date warning

The canonical filter example — done = false AND (dueDate < currentWeek()
OR dueDate IS EMPTY) — warned on every execution that the comparison also
matches objects with no dueDate, steering agents to add a notEmpty guard
that contradicts the filter's own OR-empty branch. An empty leaf on the
same property under the enclosing OR is intent to include the undated
objects, so the warning is suppressed there; an empty leaf on a different
property still warns.
- structured filters reject an RFC 3339 string on a date property with
  the unix conversion spelled out — before, the string survived to the
  store and silently matched nothing (the rule-3 hazard on the form the
  parser could not protect); convergence tests pin both request forms to
  the identical filter tree (dates, presets, set literals, booleans,
  the type pseudo-key)
- full-text pushes Limit offset+limit+1 into the store so the candidate
  budget escalates past the 100-doc floor: total capped near 100 and
  deep pages came back empty with has_more false (verified failing on
  revert with 120 indexed matches)
- global search: offset capped at 2000 with steering to the space
  search; an unknown fields entry warns instead of dropping the whole
  space from results and total; reference sets are computed lazily for
  the bare-query fan-out; spaceRefs filters space views by the v1
  ListSpaces status predicate so removing/deleted spaces get no index
  minted as a side effect
- date sorts left includeTime-less default to second granularity, so
  ordering no longer changes with the presence of the full-text tiebreak
- option-name validation skips when the store cannot list options
  instead of asserting 'no such option' about unread data
- collection membership reorder is O(n log n) with one details read per
  record (was an O(n^2) comparison-time-reading insertion sort over the
  whole membership)
- sets/collections ?fields= is rule-1 validated with did-you-mean (a
  typoed key 400s instead of returning rows that silently carry no
  properties); POST /sets answers a type filter leaf with a targeted
  'already scoped' message instead of unknown-property
- ensureSpace guard regression tests for all five Phase-4 entry points;
  placeholder host/empty-account unit tests; pagination boundary tests;
  compile-time assertion that account.Service keeps AccountID()
The 45 operation descriptions had grown a shared preamble. Sixteen repeated
Idempotency-Key, seventeen repeated dry_run, twenty cited a C-number, twelve a
section mark, twenty-two shouted in capitals. Median 269 characters, longest
1750, and the fact that only applied to THIS endpoint was somewhere in the
middle of it.

The shared behaviour now lives once in the API description: auth, the
idempotency key, dry runs, If-Match and etag, pagination, the error shape,
warnings, strict binding, body caps, what deleting does, and how a space is
addressed. An operation carries what is left, which for thirteen of them is
nothing. An empty description is the correct outcome, not a gap.

Median is 105 characters now, longest 399, and the internal references are
gone: a C-number, a section mark or a phase name names nothing a reader
outside this repository can look up, so where the rule mattered it is written
out in words instead.

TestV2DocumentProse guards it. It runs over the generated document rather than
the annotations, because that is what reaches a reader, and it covers whatever
swag rewrites on the way plus the prose that arrives from a model comment. It
fails on a C-number, a section mark, a phase name, a .md filename, a run of
three capitals outside a tiny allowlist of spellings (GET, JSON, URL and
friends), an em dash, or any description over 400 characters. The API
description is the one length exemption: it is where the shared behaviour is
stated. Against the pre-rewrite document it reports 143 failures.

Facts that left an operation went to a parameter, a response or a field
comment rather than being dropped: the delete-route steer is on the 400, the
already-deleted 404 is on the 404, the op names are on the {op} parameter, and
has_more's bounds-scoping is on the field.

v1's documents are byte-identical.
…case

The second merge from the format branch (the first was 260635c), bringing
the pre-freeze review, the Tier 1 fixes and SPEC v0.8, whose headline is that
the format's own vocabulary is snake_case: 100 identifiers, every block type,
field name, enum value and inline tag attribute the format defines.

Thirteen files conflicted, all in pkg/lib/anyblockjson. The rule that resolved
almost all of them: a format identifier takes their snake_case spelling, a
property key takes our slug vocabulary. Both sides were converging on
snake_case from opposite ends, and the regenerated goldens show the result --
"empty_placement" beside "property": "due_date", and "customStatus" passing
through unslugged because no vocabulary entry claims it.

The interesting resolutions:

  - export.go: their new id domain (seedIdLabels/idLabel/blockLabel, one
    uniqueness domain across sanitizing, compacting and generating) meets our
    C4 split of CompactIds into CompactObjectRefs+CompactBlockLabels. Kept
    both; their locals[candidate] avoid-set is subsumed by our fullIds, which
    covers both id populations rather than one.
  - flat_invariants_test.go: both branches independently wrote a file of that
    name pinning the same two invariants. No symbol collides, so both survive:
    theirs keeps the name (the invariants themselves, hostile-input driven,
    plus the snake_case walker), ours becomes flat_rules_test.go (the specific
    rules those invariants rest on).
  - FLAT.md: they added a stale-vocabulary banner to a document a8ded19
    retired. Kept the deletion.
  - import.go: their featured_properties case references a `structural`
    variable our rewrite replaced with the structuralBlockTypes map. Dropped
    the hunk, renamed the map entry instead.

The rename then had to reach the format code this branch added after they
forked, which their invariant test cannot see because it walks their tables:
blockvocab.go and fragment.go (featured_properties), markdownblocks.go
(bulleted_list_item, numbered_list_item, heading_N, is_header), viewvocab.go
(the aggregation names) and filters.go (the condition and date-preset lists).
Test fixtures followed, swept against the schema's own field set rather than a
hand-written list, so property keys and the reserved widget targets
(allObjects, recentOpen) were left alone by construction.

pkg/lib/anyblockjson is green, as are the cmd tools, core/api/wrapper,
snapshotdiff and storeresolver.

core/api/v2 is NOT green: 41 tests across core/api/eval, core/api/v2/model and
core/api/v2/service still speak the old format vocabulary in served schemas,
examples and fixtures. That is the follow-up commit; it is a v2-only sweep and
must stop at the v1 boundary, where camelCase is deliberate.
…abulary

The kind:"objectType" dispatch mismatch (type creation was completely
unreachable) turned out to be one instance of a much larger gap: v2's
own request-parsing vocabulary was never updated when the shared
AnyBlock library (pkg/lib/anyblockjson) moved to snake_case.

Fixed:
- kind dispatch (objectType -> object_type) for POST/PATCH types
- envelope fields: template_for, type_properties
- view-set vocabulary: group_by, cover_property, end_property, hide_icon,
  card_size, cover_fit, colored_groups, page_size, default_template_id,
  default_type_id, wrap_content, list_size, alternate_rows, custom_order,
  include_time, empty_placement, no_collate, and the column aggregation
  enum (count_value, count_distinct, count_empty, count_not_empty,
  percent_empty, percent_not_empty)
- block schema fields: object_id, icon_emoji, icon_image, card_style,
  background_color
- search/sort probes: date_preset
- property format multi_select (was unreachable as multiSelect)
- structured filters' condition enum in the served schema doc (the real
  validator already only accepted snake_case; the doc was stale)

Two silent production bugs this uncovered along the way, not just
schema/doc strings: keycanon.go's view-key canonicalizer and keys.go's
envelope canonicalizer were both still keyed on the old camelCase field
names, so canonicalization silently stopped the moment a caller sent
the (now correct) wire spelling. Fixed both.

Deliberately left alone: the compact filter string compiler in
pkg/lib/anyblockjson/filterstring, which still emits camelCase condition
tokens that its own downstream validator rejects (root cause of the
documented "IS NOT EMPTY" bug). That's a shared-library fix with a
wider blast radius than this v2-scoped pass -- needs separate sign-off.

go test ./core/api/v2/...: 223 -> 172 failing subtests, verified via
git stash A/B on each round (not a fluke of one run), zero regressions
introduced. Full findings, root causes, and remaining scope are in
core/api/v2/EVAL_FINDINGS.md.
Same gap as the previous commit, one level down: pkg/lib/anyblockjson's
block type enum was already migrated to snake_case (heading_1, heading_2,
bulleted_list_item, toggle_heading_1, ...), but v2's own code and test
fixtures still spelled several of them the old way.

One confirmed functional regression, not just doc/test staleness:
object.go's outlineHeadingTypes map -- which decides whether a block's
text is included in a `?outline=true` read -- was still keyed on
"heading1"/"heading2"/"heading3"/"toggleHeading1-3". Since real blocks
carry "heading_1" etc., the map matched nothing: outline reads silently
stopped showing heading text for every object. Fixed.

Also fixed two served-schema examples that were invalid against their
own schema (schemas.go's "object"/"template" kind examples, and
schemas_ops.go's replace_subtree op example, both using "heading2" /
"bulletedListItem"), and the same stale spellings across 5 test files'
fixtures (edit_test.go, create_test.go, payloadids_test.go,
object_test.go, schemas_ops_test.go).

go test ./core/api/v2/...: 170 -> 29 failing subtests (223 -> 29 across
both commits, an 87% reduction). Verified clean build, vet, and gofmt.
EVAL_FINDINGS.md not yet updated for this round -- the remaining 29 are
a mixed tail (chat mention-tag casing, a few filterstring.go-adjacent
view/search cases, some untraced) still to characterize.
…ctOrders, mention tags)

Continuing the same migration: three more genuine production gaps found
by tracing the remaining test failures instead of guessing.

- schemas_ops.go: the table row schema (opTableProps) still advertised
  isHeader; the real document validator only accepts is_header, so a
  caller following v2's own served schema for set_cell/insert_blocks
  table rows got rejected downstream. Fixed, plus the 5 test fixtures
  using it.
- viewops.go: the "groups"/"objectOrders" output-only-field guard (the
  one that gives a clear "this is output-only, don't write it" error)
  was still keyed on the old objectOrders spelling, so real object_orders
  input silently fell through to a generic "unknown field" refusal
  instead. Fixed, plus nested group_id/object_ids in test fixtures.
- inline mention tags (<mention object_id="...">): pkg/lib/anyblockjson's
  codec already renders AND parses object_id in both directions --
  nothing in production still says objectId. Fixed the one stale doc
  string (schemas.go's chat message description) and two stale test
  expectations (chat_test.go, object_test.go).

Plus a leftover multiSelect fixture and a handful of test assertions
still checking pre-fix error text (templateFor path, customOrder path,
multiSelect in a hint) that only needed to catch up to the wording the
earlier commits already corrected.

go test ./core/api/v2/...: 29 -> 9 failing subtests. All 9 remaining
now trace to one place: pkg/lib/anyblockjson/filterstring's compact
filter compiler still emits camelCase condition/field tokens
(notEqual, greaterOrEqual, allIn, notEmpty, datePreset, ...) that the
downstream validator rejects. That's the shared-library fix flagged in
the previous commits -- confirmed here to be the sole remaining cause,
not fixed in this commit.
… the compact filter compiler

The last remaining piece: pkg/lib/anyblockjson/filterstring compiles the
compact filter STRING syntax (SPEC 6.2.1) into the structured filter tree,
and every condition/field name it can produce was a hardcoded pre-migration
literal -- ten call sites, plus one JSON struct tag, plus the date-preset
value table.

Fixed, all confirmed against anyblockjson's own canonical enums
(json.go's conditionNames / datePresetNames):
- condition tokens: notEqual/greaterOrEqual/lessOrEqual/notContains/notIn/
  allIn/notAllIn/exactIn/notExactIn/notEmpty -> their snake_case forms
- the emitted node's date_preset field tag (was datePreset)
- the datePresets value table: daysAgo()/daysFromNow() and every
  multi-word preset (lastWeek, currentMonth, ...) now fold into the
  snake_case structured value (number_of_days_ago, last_week,
  current_month, ...) instead of the camelCase one. The MAP KEYS --
  the compact syntax's own function-name vocabulary the user types,
  e.g. currentWeek() -- correctly stay camelCase; that's a different,
  intentional vocabulary confirmed against the served EBNF grammar,
  not part of this migration.
- the multiSelect format check inside stringValue (ResolveFormat now
  returns "multi_select"), and doc comments to match

Same fix applied to core/api/v2/service/schemas.go's structured "filters"
kind: the date_preset field's documented enum and worked example were
teaching the same stale camelCase values.

Cleaned up every consumer's own stale expectations that assumed the old
output: pkg/lib/anyblockjson/filterstring's own test suite, and
core/api/v2/service's search_test.go / list_create_test.go / viewops_test.go
-- all of which hardcode either the compiler's output or raw structured
filter JSON using the old spelling directly.

go test ./core/api/v2/... ./pkg/lib/anyblockjson/...: all green (0
failures). This closes the loop from EVAL_FINDINGS.md: 223 -> 0 failing
subtests across five commits. go build ./..., go vet, and gofmt are all
clean.
…-validation error text

Cosmetic but genuinely wrong: the error Path and Message said emptyPlacement
while the actual field (confirmed against dataview.go's json:"empty_placement"
tag) is empty_placement. Found while auditing for other leftover camelCase
after the filterstring fix.
Mechanical merge only: the tree builds, the API adaptation follows.

Resolution rule: the anyblock branch is authoritative for pkg/lib/** and
cmd/**, so all 34 conflicts there took theirs wholesale. Verified this loses
nothing -- the four fixes this branch contributed upstream (the filterstring
snake_case condition tokens, the grammar comment, and filters.go's
empty_placement) are all present and equivalent on their side.

core/api/APIV2.md was the only conflict needing judgement. Kept THIS branch's
C2/C3/C4 rows: their copy still describes the pre-migration camelCase
vocabulary, while ours carries the snake_case decision (§8.46) and already
had C4 right (object refs full on every shape, no refs legend). Adopted their
clarifying `?ids=` comment -- object ids are never compacted -- which is the
one thing their text said better.

Four signature changes fixed with intent, not silenced:
- snapshotdiff.Compare now takes the smartblock type; ScoreCorruptionJSON
  threads it from the original document's own import rather than assuming
  a page, since the comparator uses it to know which slots the format
  legitimately omits.
- MarshalPropertyValue now also returns the key's option-id legend. Rows
  drop it knowingly: a row has no envelope to hang a legend on and select
  values have always been served there as bare names. Whether `fields=`
  should gain an id-bearing shape is a surface decision, recorded in the
  comment, not settled here.
- TypeProperty.Key split into Property (document-facing spelling) and
  InternalKey (stored key) per §2e; validateTypePropertyFormats resolves on
  the spelling and falls back to the internal key. Its issue path was also
  still /typeProperties -- now /type_properties.
- BuildRecommendedLists now returns an error, wrapped rather than dropped.

go build ./core/api/... is clean. go test ./core/api/... has 133 failing
subtests, which cluster into ~6 root causes rather than 133 problems -- 83
of them are the single §2e rename of a dataview property entry from
{key, format} to {property, format}. Catalogued in the follow-up.
…properties

The format split the member `key`, which used to mean both a document-facing
spelling and a stored id, into `property` (the spelling) and `internal_key`
(the stored key). Two API-facing slots carried the old name.

- Dataview property entries. `dataviewProperties` built {key, format};
  the schema is now {property, format} with additionalProperties:false and
  `property` required — there is no stored-key member on this entry at all,
  and the §3 chain resolves an exact stored key through `property` anyway,
  so this is a pure rename rather than a choice between the two names.
- type_properties entries, in the served `type` example and across the
  test fixtures.

This was 118 of the merge's 133 failing subtests — one rename, not a
hundred problems. 133 -> 15.
Test-side fallout of the same §2e rename: a fixture read
p["key"].(string) on a dataview properties[] entry, which is now nil and
panicked — aborting the whole service test binary and hiding the real
failure count behind an early exit (15 reported, 44 actual).
A type document no longer has a top-level `key` or `type_properties`. Both
moved into the gated `type_settings` subtree — `key` as `api_key` (it was
always the apiObjectKey slug) and the array as `property_definitions` —
alongside `layout`, `plural_name`, `default_template` and `default_view`,
which left `properties` at the same time.

POST /types takes an AnyBlock document, so it mirrors: docEnvelope gains a
TypeSettings member and CreateType's identity layer reads the slug from
type_settings.api_key. A stale top-level `key` or `type_properties` gets a
named migration steer rather than the format's own message, which talks
about property spelling and is unactionable for someone who wrote
`"key": "task"` meaning the type's api key.

PATCH /types takes v2's own partial body, so it could have kept the flat
spellings. It mirrors too, deliberately: `properties` is now name and
description only, the layout is patched as type_settings.layout, and
api_key is absent from the patch surface entirely — the slug is identity,
union-checked at create, and re-pointing it would silently break every URL
that names the type. One vocabulary across POST and PATCH (C2) beats a flat
alias this endpoint alone would keep alive.

typeDetailValue now takes the request pointer instead of a bare wire name;
it was hardcoding /properties/ and would have reported
/properties/type_settings/layout.

The served `type` example is rebuilt in the new shape and verified to pass
anyblockjson.Validate rather than assumed to.

44 -> 26 failing subtests.
…grate

Neither API v2 nor AnyBlock has been released, so a caller sending the
pre-§2a `key` or `type_properties` does not exist. The steer I added for
them was compatibility scaffolding for an empty set, and the guard against
`type_settings` on an object document restated a rule the validator already
owns.

Verified rather than assumed: anyblockjson.Validate already refuses all
three, path-addressed —
  /type_settings: only valid on type documents (kind "object_type", §2a)
  /type_properties: moved: ... states its definitions in "type_settings" ...
  /key: property "key" is not allowed ...
and its type_properties message is better than the one I wrote. One
statement of each rule, in the validator.

No test movement (26 -> 26): the removed code was unreachable for any
document a caller can now write.
…and kind:"template"

Three more §2a/§2b/§2e consequences.

- §2b collapsed the flat icon_emoji/icon_image pair into one typed `icon`
  whose `format` selects the variant. Both op payload defs are
  additionalProperties:false, so a variant the op schema does not publish is
  one a grammar-constrained decoder cannot author at all — the schema now
  carries the whole plainIcon shape (emoji/file/icon/color) plus icon_size,
  and TestSchemaOp's companion assertion was inverted to match: the flat
  pair must NOT resolve, or the guard would stop noticing.
- §2e renamed the property block's `key` to `property`, in the op schema and
  in viewops' three dataview-property sites — two reads (membership, and the
  bare insert_view column builder, which was silently producing a one-column
  view) and one write.
- POST /templates now injects kind:"template" the way POST /types injects
  kind:"object_type". The endpoint is the kind; making a caller restate what
  the URL already said is the trap C2 exists to avoid.

26 -> 19 failing subtests.
Stale spellings the earlier passes did not reach: the property block's
`key` (fixture and its reader), two type documents still carrying an
envelope `key`, a second test-side `["key"]` read that panicked and
truncated the run again, the /typeProperties issue paths (one in
resolver.go, three assertions), and the served template example, which
needs kind:"template" like every other template now does.

19 -> 12 failing subtests, no panic.
…rmat

Mirroring type_settings on PATCH took the icon off the patch surface
entirely — properties.icon_emoji was the only way to set one, and §2b moved
it to the typed envelope member. That was a capability hole, not a cleanup,
so the patch body gains `icon`.

It is decoded by handing a minimal type document to anyblockjson's own
importer and reading back the details it set, rather than by restating which
of iconEmoji / iconImage / iconName / iconOption each variant writes. That
table is the format's, and §2b exists precisely because those nine flat keys
had no single owner; a copy here would be the same bug one layer up.
Validation issues are re-addressed onto /icon so the caller is told about
the thing they sent.

Two test-side consequences, both taken honestly:
- the slug-spelling table moves to {"icon":{…}} and
  {"type_settings":{"layout":…}}, and drops its camelCase case — nothing
  shipped, so there is no old spelling to keep working.
- the duplicate-spelling case is removed rather than contrived. With
  `properties` down to name and description, both single-spelled, a
  duplicate of anything else is refused as not-updatable first — and that
  is the better error. The guard stays for the key that gains a second
  spelling; the comment says why it currently cannot fire.

Also reverted an over-reach from the previous commit: two /key assertions in
TestV2CreateProperty are the PROPERTY surface's own body field, not the
type envelope, and were correct as they were.

12 -> 8 failing subtests.
…is now refused

- The GET-type corpse test decodes type_settings.property_definitions with
  `property` instead of a top-level type_properties with `key`.
- A create body still carrying an envelope `key`.
- The apiObjectKey forgery case now asserts a REFUSAL, not a drop. §2a made
  apiObjectKey a type_settings member, so the format rejects it in
  `properties` before the create path ever sees it — one layer earlier and
  stricter than the API's own drop. The invariant is unchanged (a forged
  slug never reaches the mint); only the mechanism moved, so the test
  asserts the mechanism that now enforces it, including the sharp case where
  the forged value would have been the only apiObjectKey.

8 -> 5 failing subtests.
- ListViews: a block fragment is an ENVELOPE now, carrying the §9a typed
  legends beside its blocks, not a bare array. This surface serves views
  only and their option values are already names, so the legends are read
  past rather than forwarded.
- The id-shapes fixture never put its link target in the store. §9's
  missing-reference rule rewrites a reference the space does not hold to
  `_missing_object`, and storeresolver answers that question — so the read
  was correctly saying "missing" to a question these subtests are not
  asking. Checked before assuming a wiring gap: missingFromSpace fails SAFE
  (no existence resolver means "not missing"), so this was only ever a
  fixture hole, never a production one.
- The eval testdata carried heading1 and isHeader — the same two renames as
  the Go fixtures, in JSON the earlier sweeps did not reach.

go test ./core/api/... and ./pkg/lib/anyblockjson/... are green, go build
./... is clean, and vet and gofmt are clean on both trees.
…o longer exists

Two silent breakages in the layer on top of the API, neither caught by
tests — because the tests stub the API with the same stale shapes the code
expects, so both sides drifted away from production together.

- describe decoded a type document as {key, typeProperties[{key}]}. §2a/§2e
  moved all three. json.Unmarshal leaves absent members zero, so describe
  answered "this type has no properties" — the one answer an agent acts on
  without questioning. It now reads type_settings.property_definitions and
  each entry's `property`, falling back to internal_key.
- the A2 option-name guard keyed on format "multiSelect", which the format
  now spells multi_select, so the guard silently stopped firing for every
  multi-select property.

To stop the class rather than the instances, fixture.stub now validates any
stubbed body that IS an AnyBlock document against the format, and fails the
test naming it: a stub the API could never serve tests nothing. v2's own
envelope additions (etag, warnings, outline, markdown) are stripped first
and partial ?block= reads are skipped — those are v2's contract, not the
format's, and validating them as the format's would fail on our own design.

The guard immediately found four more stale stubs (heading1) the earlier
sweeps had not reached.
…true

Setting a select value to a name the property does not hold used to MINT
that option silently. The value names a label, not a create, and an
unmatched one is far more often a typo or a hallucinated label than a
deliberate new option — while a minted option joins the property's
vocabulary for every object and every member of the space, with no delete
surface in v2. Airtable's `typecast` and monday's `create_labels_if_missing`
both default off for the same reason.

Now: `?create_options=true` is the consent, parsed group-wide like dry_run
(one parse, one closed value set, one refusal, and a route added later
inherits the gate). Default off refuses, naming the property, the value,
and both ways forward.

The refusal lives PRE-LOCK, in guardCreateMissing, which already walks the
op payloads before any RPC — so a refused PATCH touches nothing. The
resolver keeps a backstop for the create paths, which have no such guard,
and both call one optionConsentError so the rule cannot be worded two ways.
The probe deliberately still RECORDS would-be creations instead of
refusing: it exists to hand the guard the whole pending list, and a probe
that refused would leave the guard blind — which is exactly what my first
attempt did.

Using an option that already exists needs no flag: the gate is about
minting, not about referencing, or the flag would tax every ordinary write.

Threaded explicitly through all seven resolver call sites rather than
defaulted, so each states its answer: CreateCollection passes none (its
body is {name, items} — no property values can reach the resolver) and the
read-only format resolver denies on principle.

Tool layer: the CLI's --create-missing and the wrapper's AllowNewOptions now
send the flag as well as skipping the wrapper's own A2 pre-validation.
Without that second half the flag would only have moved the refusal from
the client to the server.

SKILL.md, the OpenAPI descriptions and the CLI help all say the new rule.
`create_options` read as "create options", which is what POST /properties
does deliberately. This flag is narrower: it only reaches names a property
does NOT already hold — an existing option resolves without it — and the
name should say that, since the whole point of the gate is that a caller
understands what they are consenting to.

It also matches the vocabulary already in the code (guardCreateMissing) and
on the CLI (--create-missing), so the wire name, the Go identifiers and the
user-facing flag are now one word apart instead of three different ones.

Renamed through the middleware, the handlers, the service signatures, the
resolver's refusal hint, the OpenAPI @PARAM docs and SKILL.md — the Go
identifiers alongside the wire name, so a reader never has to map between
them.
@requilence
requilence force-pushed the go-7383-anyblockjson branch from 971894d to 52df8b7 Compare August 27, 2026 09:06
@requilence
requilence force-pushed the go-7383-apiv2-phase0 branch from acc1226 to 3a138f9 Compare August 27, 2026 09:29
@github-actions

Copy link
Copy Markdown

New Coverage 56.5% of statements
Patch Coverage 78.4% of changed statements (8058/10279)

Coverage provided by https://github.com/seriousben/go-patch-cover-action

@requilence requilence closed this Sep 13, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 13, 2026
@requilence

Copy link
Copy Markdown
Contributor Author

closed in favor of #3266

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants