Skip to content
AbloAblo
Esc
navigateopen⌘Jpreview
On this page

v0.35.0

Minor Changes

The @abloatai/ablo/agent entry point is removed, along with the single name it exported, Agent.

There is no separate agent SDK, and in practice there has not been one for some time: an agent is the same client with a different transport. Ablo({ schema, apiKey, transport: 'http' }) returns a stateless, request/response client whose credential is its identity, carrying the same typed ablo.<model> surface a browser or a server action uses, minus the parts that need a socket — the local reads and onChange, which are absent from the type rather than failing at runtime. A second entry point offered a second way in and, mostly, a second thing to keep in step with the first. If you imported from @abloatai/ablo/agent, import from @abloatai/ablo and select the transport.

ClaimEvent.participantKind is also removed. A claim event is one transition in a lease’s lifecycle — acquired, queued, granted, lost, rejected, expired — and it identifies the actor that moved it. Whether that actor was a person, an agent, or the system is a property of the holder, not of the transition, and stating it on every event meant maintaining the same fact in two shapes. Read it where the holder is described instead: ablo.<model>.claim.state({ id }) returns the current holder with its participantKind, which is what the “an agent is editing this” affordance in a UI has always read.

The client is now the coordination layer, and the things built on top of it are capabilities you install rather than parts of the client you get whether you use them or not.

import { Ablo, humans } from '@abloatai/ablo';

const ablo = Ablo({ schema, apiKey, plugins: [humans()] });

humans() declares the local, watchable copy: the offline store, live queries, presence, and the framework bindings — everything that exists because a person is looking at the screen. What remains underneath is what coordination actually needs: commit, read, observe, claim. Omit plugins and humans() is installed for you, so an application that renders anything today keeps working with no change; naming it in the list only makes visible what was already true.

There is no agents() counterpart, and the absence is the point. An agent is not a special caller that needs its own mode — it is the ordinary one, and what it needs is the client without the materialiser. That is the same observation behind removing the /agent entry point in this release: there was never a second SDK to enter, only a client with a transport that does not open a socket. Ablo({ schema, apiKey, transport: 'http' }) is the whole of it, and because humans() requires a duplex connection, asking for it on that client fails while the client is being constructed, with an error naming the plugin, rather than resolving to a presence stream that can never receive a frame. A plugin listed twice fails the same way.

The rearrangement is also why the reactive ablo.<model> surface composes from the transport-independent one rather than restating it — the note on the renamed local reads describes what that buys — and why track, a durable premise that keeps no local copy, sits in the core where an agent can reach it without installing a materialiser it has no use for.

This is a seam, not a finished migration. humans() today constructs the presence stream and declares the constraint that keeps it off a stateless client; the rest of the materialiser — the store, the bootstrap, the delta pipeline, the framework bindings — still lives in the composition root and moves behind the same door as the plugin context grows to carry what each piece needs. The contract those pieces will arrive through is the one published here, so a capability written against it now keeps working as they do.

The shape of a claim’s meta is now declared once, on Register, beside your schema:

declare module '@abloatai/ablo' {
  interface Register {
    Schema: typeof schema;
    ClaimMeta: { blocks: string[] };
  }
}

From then on claim.state({ id })?.target.meta, every entry in claim.queue, and the claim you hold under await using all read that shape, and the typeof guard each of those reads used to need is gone. The same declaration types what you write — claim({ id, meta: { blocks } }) — so the writer and every reader agree by construction rather than by convention, which is the half a per-call type argument could never reach: naming the shape at the read site said nothing about what the write site was allowed to put there.

Prefer the registration to the per-call claim.state<MyMeta>({ id }), because one declaration is one place to change when the metadata grows a field. The type argument remains supported and is still the honest answer for a program carrying more than one metadata shape; only its default moved, from a bare record to whatever you registered. A program that registers nothing sees no change at all — target.meta stays the loose record it has always been.

What travels between participants is untouched. Claim metadata is carried verbatim and never interpreted, and the protocol keeps accepting any object it is given, so a peer on a newer build can send a field this one has never heard of and it still arrives intact. The declaration is a promise about what your own program writes, not a filter on what it receives.

A model’s conflict stance can be written as a chain through one name.

import { coordination, model, z } from '@abloatai/ablo/schema';

export const cards = model(
  {
    title: z.string(),
  },
  {
    conflict: coordination.humansOverwrite().agentsReject(),
  }
);

The nine disposition rules were the only loose functions left in the schema DSL. Everything else there arrives under a noun that owns it — relation.belongsTo, field.string, mutable.lazy — but declaring that humans overwrite and agents yield meant importing three separate names to say one thing, and a schema with a couple of stances spent more of its import list on conflict rules than on the rest of the language combined.

So the rules now hang off coordination, and each one returns an axis that is still open to the next, the way a field chains its own modifiers. The rules themselves are unchanged, and each remains the single place its disposition map is written; the chain delegates to the standalone function rather than restating it. Those standalone functions stay exported, and coordination() still merges them, which is the form to reach for when the rules are assembled at runtime rather than written out:

const stance = coordination(humansOverwrite(), agentsReject());

Both forms produce the same plain, serializable map keyed by the committer’s participant kind — { user: 'overwrite', agent: 'reject' } — and the engine reads it at the commit chokepoint exactly as before. A kind left unnamed still falls through to the engine default, and a kind named twice still takes the later rule.

captureTransactionFailure on the observability provider is renamed to captureMutationFailure. If you pass an observability provider to Ablo(...), rename that one method; its argument is unchanged.

The rename frees a word that had been doing two jobs. A transaction, in this system, is a unit of write that settles — the thing you author, hand to the engine, and get a receipt for. But the same word also named the client’s optimistic queue, the buffer of writes that have been applied locally and are still waiting for the server to agree. The callback belonged to the second meaning: it fires when a queued write has exhausted its attempts and will never land, so the local change is rolled back and something upstream needs to hear about it. Calling that a transaction failure invited the reading that a settlement had failed, which is a different event with a different remedy — and as the settlement layer grew a vocabulary of its own, the collision stopped being cosmetic.

There is no compatibility alias, and the reason is worth stating, because it is the opposite of the usual one. captureMutationFailure is a required member of the provider interface. A provider still carrying the old spelling therefore does not satisfy that interface at all: the compiler stops the build and names the missing member, at the line where the provider is constructed. An optional alias would have made that build succeed while the engine went on calling a method the provider does not implement — quiet in exactly the way a renamed callback must not be. The compiler is the better messenger here, and this note is the rest of the message.

debug, logLevel, and persistence are no longer accepted on the stateless HTTP client. They remain exactly where they have always worked — on the default client, over the socket.

They were reachable on Ablo({ transport: 'http' }) only because that client’s options type was assembled from the whole composition bag rather than from the fields the transport actually reads, and nothing on the HTTP path ever read them. persistence configures a local store a stateless client does not keep. debug and logLevel turn on the [Ablo] coordination trace, which narrates presence, claim grants, and queueing — frames that arrive over a socket an HTTP client never opens. Passing any of the three was accepted and did nothing, which is the kind of option that costs a reader an afternoon before they conclude the feature is broken.

If you passed one to an HTTP client, delete it: nothing changes, because nothing was happening. If what you wanted was the trace from a server-side worker, it is not a setting you were missing — a stateless agent holds no live session to narrate, and its writes are visible in the audit log and in ablo logs instead.

A model’s load strategy is now 'instant' or 'lazy', and nothing else. The word 'manual' is gone, along with the mutable.manual(...) helper that set it.

The strategy had drifted into two declarations that never agreed. The authoring side, reached through @abloatai/ablo/schema, offered 'instant' | 'lazy' | 'manual'. The runtime side, reached through @abloatai/ablo/core under the same name, offered instant, lazy, partial, explicitlyRequested, and local. Importing LoadStrategy from the two paths gave you two different types, and no compiler anywhere would tell you which one you had.

Only instant and lazy were ever real. 'manual' promised loading a model “only when you explicitly call sync.model.load()”, a method that was never built; a model declared manual resolved to lazy and loaded on first access like any other. partial had live branches in the client but no way to declare it, and explicitlyRequested and local had neither. So the correction is mostly the removal of vocabulary that described behavior the engine did not have — a model that said manual was already behaving as lazy, and continues to, which is why this changes what your schema is allowed to say rather than what your program does.

Replace load: 'manual' with load: 'lazy', and mutable.manual(...) with mutable.lazy(...). readOnly.internal(...) stays, and its guarantee is unchanged and worth restating plainly: it is a write boundary, not a read one. Clients cannot commit to an internal model, and the server rejects any attempt. Its rows still reach a client that reads the model.

LoadStrategy now has one declaration behind both import paths. It reads as a value where the engine branches on it — LoadStrategy.instant — and as the plain string an author types in model(…, { load: 'instant' }), and the two can no longer name different sets.

Two names join it on @abloatai/ablo/schema. DEFAULT_LOAD_STRATEGY is the strategy a model gets when it declares none, and loadsAtBootstrap(load) answers whether a model’s rows arrive in the bootstrap payload or on first access. The predicate is exported because both halves of the system have to agree on it: the client builds its bootstrap subscription from the schema and the server assembles the payload from the same schema, and they had been asking the question in three different spellings — load !== 'lazy' in one place, load === 'instant' in another. Against two members those agree. A third member would have had one side enrolling it and the other withholding it, which surfaces as rows that never arrive, a long way from the line responsible.

  • The three synchronous reads on ablo.<model> are renamed. get(id), getAll(options), and getCount(options) become local.retrieve(id), local.list(options), and local.count(options).

The old names were a pair that carried no information. get and retrieve are synonyms in English, so a reader who found ablo.tasks.get(id) beside ablo.tasks.retrieve({ id }) had no way to tell from the words which one waited on the network and which one did not — the difference had to be memorised, and it was memorised imperfectly. Two bugs reached review that way, both of them a synchronous read standing in for an asynchronous one against a graph that had not warmed up yet, both of them invisible in the diff because the line read exactly like the call it should have been.

The new names say the thing out loud. Every verb is the verb it already was on the asynchronous side; the only addition anywhere in the design is the word local, and it is a narrowing rather than a claim about its sibling. retrieve consults the local graph first and falls back to the network; local.retrieve is restricted to what is already resident, which is also why it can hand back a value instead of a promise. There is nothing to await. Reading ablo.tasks.local.list({ where }), you know both what it does and what it costs without leaving the line.

The reads themselves are unchanged — same options, same lifecycle state filter, same reactivity inside useAblo selectors and in render. Only the path to them moved, and it is the only path: the local reads are not also exposed at the top level, because a namespace that duplicates what sits beside it stops disambiguating anything.

Underneath, the rename arrives with a structural change that will keep the surface honest from here on. The reactive ablo.<model> type was hand-written as a second list that happened to agree with the transport-independent one, and nothing linked them: a verb added to one said nothing about the other. It is now composed from that base rather than restated, so a verb added there arrives on the reactive client on its own. track — registering a durable premise on a row — is the first to arrive that way.

Migrating is mechanical, but reach for the compiler rather than a search. getAll and getCount are distinctive enough to find by name; get is not, and in a codebase of any size it hides among the Map.get and headers.get calls that a search cannot tell it from. Removing the old names from the type turns the question into a list of type errors, each one a site that genuinely needs to move.

A capability is declared in the vocabulary you write, and every other spelling of it derives from that declaration.

import { grantedOperations, modelWireNames } from '@abloatai/ablo/auth';

const operations = grantedOperations(
  { documents: ['read', 'update'] },
  modelWireNames(schema.models)
);

The same grant used to be spelled five times: a literal union in the resource types, a z.array(z.string()) on the wire, a field-by-field parser in the mint route, an object literal in the response type, and a hand-written model.verb array at every caller that mints without the SDK. Nothing failed when those drifted apart. The drift surfaced later as capability_scope_denied on a grant the caller was certain it held, at a call site with no view of the spelling that had gone stale.

They are now one module on @abloatai/ablo/auth. capabilityOperationSchema holds the verb vocabulary; grantedOperationSchema derives the wire pattern <model>.<verb> from it as a template literal, so tightening the verbs cannot leave a stale regex or a stale published contract behind. CapabilityCan<S> narrows the declaration to one schema’s models, so can: { tasks: ['update'] } fails to compile against a schema with no tasks model.

modelWireNames is the derivation worth knowing about, because it is the one people got wrong by hand. A model whose type name is overridden — schema key documents, type name Document — must be granted as document.update, and a caller who works that out by eye learns otherwise at enforcement time. Pass your schema’s models and the map is built for you. It is a required argument rather than an optional one: an omitted map would mint the schema key verbatim, which is right for most models and wrong for exactly the ones that need it.

Two more derivations ride along. expandReadYourWrites appends <model>.read for every model the grant can write, because a scoped agent that may update a row has to be able to read it or the read gate starves the writes the grant allows — write verbs stay the source of truth, reads are derived and deduped, and a model the grant cannot write stays unreadable. And a grant is now checked against the schema at the mint: capabilityModelAliases accepts any of the three names one model answers to (type name, schema key, table name), and unresolvableOperations reports the ones that name nothing, so a typo is a rejected mint rather than a credential that looks healthy until its first write.

Breaking

ExchangeApiKeyRequest.operations narrows from readonly string[] to readonly GrantedOperation[]. If you assembled that array by hand, the compiler will now reject any entry that is not <model>.<verb> — including the '*' wildcard, which was never a member of the wire form and is no longer typable as one. Build the array with grantedOperations(can, modelWireNames(schema.models)) instead of composing the strings; that is the same call the SDK makes, and it resolves the type-name override you would otherwise have to remember.

Three error codes carried the word “intent”, left over from the vocabulary the coordination layer used before it settled on claims. The word had come to mean two unrelated things in the same registry, so both are now named for what they are.

invalid_intent is removed. It was raised when a claim request failed to name the model and row it targeted — which is what malformed_claim already described, in the same words, for the same failure arriving over the WebSocket instead of over HTTP. The two have been merged into malformed_claim; a caller switching on invalid_intent should switch on malformed_claim instead, and the HTTP status is 400 either way.

slide_intent_missing_deck_id and slide_intent_unknown_sibling are renamed to slide_position_missing_deck_id and slide_position_unknown_sibling. These were never about claims at all: they report that a slide created at a position stated relative to its neighbours — “before this one” — could not have that position resolved, because no deck was named or because the neighbour is not in it. An earlier rename had swept the word “claim” through their descriptions and left them reading as contention errors, so their messages are rewritten to say what actually went wrong.

malformed_claim moves from the claim category to validation, alongside malformed_subscription. The code, its 400 status, and its meaning are unchanged — only the grouping it appears under in the error documentation. The claim category now holds only genuine contention, which is what its name promised.

model() now takes a shape and one options object. Relations, which used to hold a positional argument of their own in between, are the relations key on those options.

documents: model(
  {
    title: z.string(),
    projectId: z.string(),
  },
  {
    relations: { project: relation.belongsTo('projects', 'projectId') },
    conflict: coordination(humansOverwrite(), agentsReject()),
    load: 'lazy',
  }
),

The middle argument was the problem. Most models have no relations at all, yet nearly every model has something to say about itself — a table it maps to, a load strategy, a conflict disposition — and the only way to reach that third argument was to write the second one first. So the schema filled up with empty braces standing in for edges that did not exist. In this repository alone the placeholder appeared 104 times, which made {} the single most common thing anyone ever passed in that position: more often than any real relations object. A reader opening a schema met the empty braces before they met the model.

Nothing about relations themselves changes. They are built with the same relation.belongsTo and relation.hasMany factories, they carry the same options, and the engine reads them exactly as before — to index foreign keys, to order inserts so a parent lands before its children, and to generate the accessors behind task.project. They have simply stopped being a position and become a name, which is what they always were to everything downstream.

Migrating is mechanical, and the compiler finds every site. A call that passed an empty placeholder drops it:

-model({ title: z.string() }, {}, { groups: { root: 'workspace' } }) +
  model({ title: z.string() }, { groups: { root: 'workspace' } });

A call that passed real relations folds them in under their own key:

- model({ title: z.string(), projectId: z.string() }, {
-   project: relation.belongsTo('projects', 'projectId'),
- }, { load: 'lazy' })
+ model({ title: z.string(), projectId: z.string() }, {
+   relations: { project: relation.belongsTo('projects', 'projectId') },
+   load: 'lazy',
+ })

A call that passed only fields is untouched.

app.current_user_id is now a reserved session setting, so a schema can no longer claim it — and a read that cannot be scoped to the person asking says so with user_scope_not_enforced instead of quietly returning rows.

Ablo’s direct-write connection already applied a fixed bundle of settings before your DML — the organization, the project, the environment, the sandbox, the acting participant — and sessionSettings let a schema forward any of those into a differently-named setting its own policies read. What a schema could never do was reassign one of the engine’s own settings, because that would let a schema push relax the scoping under which Ablo writes. RESERVED_SESSION_SETTINGS is the list that prevents it, and the engine now sets one more: the acting user.

The setting carries a person’s id when a person is behind the write, and '*' when the request comes from a backend credential acting as the organization itself. Absent identity writes the empty string rather than leaving the previous value on a pooled connection, so a policy reading it denies rather than inheriting. Your own policies are welcome to read it; they simply cannot be the thing that decides what it contains.

If your schema mapped app.current_user_id through sessionSettings, that entry is now dropped at authoring time and at runtime, and the engine’s own value applies instead. Map a different name if your policies need a value you control.

The new error code covers the one arrangement where that boundary cannot hold. A plane served from its retained log carries the organization and the project on every row but not the owner, so a rule that names a person has nothing to act on there. Rather than fold the rows and return a plausible answer, such a read is declined whole — a member reading a colleague’s private records would otherwise be indistinguishable from a member reading their own. Reads made by a credential acting for the organization are unaffected, as is every plane served from its tables.

All of this now has a page. The seam has been in the SDK since 0.32.0 and under this name since 0.33.0, documented both times only in a release note — which is a poor place to keep a feature rather than announce one: a reader with row-level-security policies had no way to discover that Ablo sets an identity context at all, let alone that they could point their own policies at it. Session Settings states what the engine sets before every write, which identities a mapping may name, which settings are reserved and why, and what happens on a plane served from its log. Read it with npx ablo docs session-settings, or on the site.

causedByTaskId is removed from MutationOptions — and so from the per-verb params that compose it, ModelCreateParams, ModelUpdateParams, and ModelDeleteParams — together with the seven turn_* error codes that existed to police it. It also leaves CommitContext, where the server carried it from the wire to the stored row. The field was never usable for its stated purpose: setting it was the one reliable way to have a write refused.

It was the surviving half of an agent-turn protocol whose other half was never built. The idea had been that an agent would open a turn, write under it, and close it, so that an auditor could later ask what an agent did because a person asked it to — a prompt at the root of a tree of changes. What actually shipped was only the check. Every commit that carried a causedByTaskId was validated against a task record, and nothing in the system has ever opened one, so the record was never there. A caller who supplied the field had the entire batch rejected with turn_validation_failed; a caller who left it null passed straight through. The prudent thing to do with a documented, public option was to never touch it, and the field spent its life as plumbing threaded through the wire protocol, the settlement envelope, the stored row, and the replication consumer, carrying null on every path.

Nothing is lost by its removal, because write attribution never depended on it. A delta already records who made the change and on whose authority — the actor, the onBehalfOf principal behind a delegated write, the capability that authorized it, and the claim the write was made under. Those answer “who did this and by what right” without asking the caller to maintain a lifecycle the engine never opened on their behalf. If your code passed causedByTaskId, delete the argument; if it branched on turn_validation_failed, that branch was unreachable and can go with it.

The removed codes are turn_validation_failed, turn_open_failed, turn_close_failed, turn_not_found, turn_foreign_agent, parent_turn_not_found, and parent_turn_foreign_agent. ERROR_CONTRACT_VERSION moves to 2026-07-19, as it does whenever a code leaves the registry. The stored-row schema loses its provenance slice with them: deltaProvenanceSchema and the DeltaProvenance type are gone, and syncDeltaRowSchema is now the core and attribution slices composed, which is what it had always described in practice. On the wire the field was optional and nullable, so a client that still sends it is accepted and ignored, and a client reading deltas sees one fewer always-null key.

The storage goes with it. The caused_by_task_id column leaves sync_deltas, agent_actions_log, and source_write_intents, and the two tables that were never written — agent_tasks and agent_task_prompts — are dropped along with the agent_surface enum that typed them. This looked at first like a separate versioned migration, because the audit chain hashes the lineage id into every row’s signature and rewriting those signatures is not something a release should do quietly. It turned out not to need rewriting. Every row was hashed with a null in that position, so the canonical byte layout the stored hashes were computed against is preserved exactly by passing NULL::text where the column used to be read — which is what the migration does, in the append trigger and in the rechain function, before the column is dropped. The signature is untouched and every historical row_hash still verifies. The packages/audit-chain reproduction freezes the same position, so the two implementations cannot drift apart on it.

Eight names that were renamed in an earlier release, and have shipped since as aliases beside the names that replaced them, are removed.

Removed Use
CommitOutboxRecord PendingWrite
CommitOutboxStore DurableWriteStore
MeshParticipantStatus ParticipantStatus
planeSchema residencySchema
SchemaPlane ModelResidency
DEFAULT_PLANE DEFAULT_RESIDENCY
DELTA_RESIDENCY DELTA_PHYSICAL_STORAGE
InferModel Model

Each is a one-for-one substitution — the alias and its replacement were the same type or the same value, so nothing about your program’s behavior changes with the name. InferModel is the one worth a sentence: Model<typeof schema, 'tasks'> reads as the domain rather than the machinery, which is why it became the published spelling, and the old name is gone from @abloatai/ablo/schema and from the Ablo.Schema namespace both. The type it named still exists underneath, because Model is defined in terms of it rather than as a second name for the same idea.

An alias earns one release of overlap. That is what the surface snapshot in this repository records and enforces: a published name may leave only from a release that shipped it marked deprecated, which is the release in which a reader could have found out. These eight were marked in 0.34.1, so this is the release that may drop them, and dropping them is what keeps the rule a rule rather than a preamble on a list that only grows.

DELTA_RESIDENCY is the one whose replacement is not merely a rename. Residency and classification are separate axes — where a column physically lives, and how sensitive its contents are — and one constant named as though they were the same question. DELTA_PHYSICAL_STORAGE answers the first; DELTA_DATA_CLASSIFICATION, which has been exported alongside it, answers the second. If you were reading DELTA_RESIDENCY to decide handling rather than placement, the classification constant is the one you wanted.

ablo docs reads the documentation for the version you installed, and the public docs routes now serve the whole corpus instead of two thirds of it.

A documentation URL always describes the newest release. A package in node_modules is frozen at whatever version was published. Those two facts drift apart the moment a project pins a dependency, and the drift is invisible from both ends: the docs are correct, the code is correct, and the agent reading one to write the other produces a call that does not exist. get, getAll, and getCount became retrieve and list in 0.35.0, so an assistant working in a project still on 0.34 reads the current page, writes retrieve, and watches it fail against the package sitting beside it.

The documentation already travelled in the npm tarball; nothing read it there. ablo docs lists every page with what it covers, and ablo docs <page> prints one as markdown — from the files shipped alongside the code they describe, so they cannot disagree with it, and with no network at all, which is the condition most agent work now runs under. ablo docs --json gives the same list to a program. A new @abloatai/ablo/docs entry point exposes the catalog for anything that wants to build its own reader.

The same catalog now answers /api/docs/*. That surface had been maintained by hand and had fallen twenty-one pages behind: coordination, the page explaining how claims work, returned a 404 while the site published it, and so did sessions, webhooks, migration, deployment, and every example but two. Nothing failed when the list went stale, which is why it stayed stale. Pages are now discovered rather than listed, so one is reachable the moment it is written, and only what ships in the package is reachable at all.

One address changed meaning. /api/docs/agents returns the Agents guide, the page the docs index has always linked under that name; the install playbook that briefly answered there is at /api/docs/AGENTS.md, where it also answered before.

GET /v1/logs answers two questions now — what changed, and who is working on what — through one envelope and one cursor.

import { feedEventSchema, parseFeedCursor } from '@abloatai/ablo/wire';

const page = await fetch(`${baseUrl}/v1/logs?after=${cursor}`, {
  headers: { authorization: `Bearer ${apiKey}` },
}).then((r) => r.json());

for (const event of page.data) {
  const entry = feedEventSchema.parse(event);
  if (entry.object === 'log_event') applyChange(entry);
}
cursor = page.next_cursor;

An entry is discriminated on object, so a reader that knows only the arm it came for parses the whole page and skips the rest — which is what lets a second arm be added to a feed that callers are already following. The two arms stay separately sequenced on purpose. A claim is a lease rather than a settled fact, and allocating its positions from the delta sequence would put ephemeral leases into the log that clients materialise rows from and that WAL-echo promotion and compaction operate over, where a burst of claim churn would be indistinguishable from committed change. They share a reading, not a sequence.

That is why the cursor carries a position for each, encoded "<log>.<claims>". A bare "<log>" still parses and resumes at claim position zero, which is the right answer for every cursor issued before this existed: a caller who never asked for claim events has no claim position to preserve. parseFeedCursor, formatFeedCursor, and feedCursorAdvanced are exported from @abloatai/ablo/wire alongside feedEventSchema, logListResponseSchema, and claimEventSchema, so a program that builds its own reader states the grammar once. claimRecordSchema and heldClaimStatusSchema join them on @abloatai/ablo/coordination — the one claim record, and the peer-visible projection of it.

GET /v1/claims gains the filters the audit log already had: actorId, actorKind, onBehalfOfId, and capabilityId, beside the existing model and id. The names are audit’s names deliberately. Asking what an agent is doing and asking what it did should not require two vocabularies for one idea, and now the tense is the only thing that differs.

The claim arm has no producer yet. Claim transitions are broadcast and recorded nowhere, so there is no sequence to read a position from, and the route emits only log_events today — with no request parameter for selecting arms, because a knob that cannot be honoured is how a contract comes to describe a server that does not exist. What ships here is the definition that producer will fill: the union parses today’s pages unchanged, and the cursor already carries the second position, so a follower written now keeps working when the arm arrives.

The cursor fix

A malformed after is now an error rather than a position. The previous reader was parseInt(raw, 10) with a NaN falling back to zero, so a truncated or garbled cursor resumed from the beginning of the log and replayed it in full — while looking exactly like a working follow, with the damage scaling to how long the log had been running. Such a request is now declined with invalid_request, naming the after parameter and how to recover. ablo logs carried the mirror of the same bug: it read the cursor with Number(...), which is harmless while a cursor is a bare delta id and silently wrong the moment it has two parts — Number('42.10') is 42.1, which re-serialises as '42.1' and resumes eight claim positions late. The cursor is now a string end to end, passed back exactly as it was issued.

legacyCompatibleCommitReceiptSchema is removed from @abloatai/ablo/wire. The WebSocket client now parses a commit acknowledgement with commitReceiptSchema, the canonical one, directly.

It was a compatibility decoder rather than a contract: a z.preprocess step that supplied fields older servers omitted and coerced their string lastSyncId into a number before handing the result to the canonical schema, which did the real work. It was careful in one respect worth recording — it never invented a source correlation, so an old or malformed queued receipt still failed closed rather than being promoted to something the server had not said.

The servers it decoded for predate the commit contract, and the hosted engine has not spoken that shape in some time. Keeping the wrapper meant every receipt on the socket path went through a normalizer for a case that no longer arrives, and it left two spellings of “a receipt” on a published surface whose entire purpose is to have one.

This matters only if you run a self-hosted engine older than the commit contract: its acknowledgements will now fail to parse, and the write is reported as commit_no_result — pending and safe to retry — rather than being silently normalized. Upgrade the engine. If you imported the schema to parse receipts yourself, commitReceiptSchema is the replacement and always was the one doing the parsing.

Patch Changes

ablo login, ablo mode, and ablo status now say what the key in your hand can do, and status labels the plane your credential reaches as acts on rather than env.

Logging in provisions a pair — a secret sandbox key and a restricted production one — so that a stolen CLI config cannot deploy to production. That protection was deliberate and it was also silent. login confirmed the sandbox and moved on, mode production printed a checkmark, and status showed the production key as twelve characters of prefix. Nothing said the key could not push, so the first notice was a 403 in the middle of a deploy, at the moment it cost the most to learn.

Each of those three commands now names the capability at the point it hands you the credential: the key rows in status carry what each key does beside how long it lasts, the line under push says which secret key deploys, and ablo status --json reports effectiveKey.kind so a pipeline can check before it pushes instead of after it fails. The wording has one definition site, so the command that stores a key, the command that switches to it, and the command that reports it cannot describe it three different ways.

The renamed status line is the same fix in smaller form. mode and env printed as peers and were not: one is the environment you selected, the other is the one your credential actually reaches, and when they diverged neither word said which was which. acts on states the fact. The --json output is unchanged apart from the added kind.

Was this page helpful?