@niscorp/vex
0.4.2
Patch Changes
2a6f8d6: An insert's
onConflict.setis held to everymatchrule of the update phase, one on a column of the conflict target included: each becomes a WHERE on the DO UPDATE half, so a conflict with a row the caller may not update writes nothing and returns no row.Before, a
matchon a conflict-target column was left out, on the reasoning that the insert pins that column from scope. Where the policy's insert phase does not pin it — no insert rule for that column, or two merged roles of which one may insert anywhere — an entry that takes that column's value from the caller let them overwrite, and read back, a row their update rule excludes.915efe6:
@niscorp/vex/pgexportscreatePgPool(pool, parsers?), which shapes a node-postgrespg.Poolas thePgPoolthe Postgres adapter and cache take, with atransactionthat checks one client out of the pool and runsBEGIN/COMMIT(ROLLBACKwhen the callback throws) on it — what the cache'sinit(), batch mutations and per-read statement timeouts need. It also exportsRAW_DATE_PARSERS, the same constant as@niscorp/vex/pglite's.pgis now listed as an optional peer.
0.4.1
Patch Changes
6680ad6:
createQueryDslhands the query agent the schema and the DSL spec as compact text instead of JSON: an entity per line with its relations and indexes, and the DSL as types that keep every description, property and allowed value. On a 15-entity CRM schema the fixed part of each call drops from about 36,000 characters to about 13,000. On 19 queries a screen builder asked for, the agent answered all 19, with the same row counts as before, at 44% of the cost and a median of 3.5 s per query instead of 5.1 s. The query it writes is still validated against the full DSL schema.6680ad6: A shape can declare a field as a Prism expression over the row's stored fields, and
createShapeMappercomputes it with no model call.[{ close_date: '', close_date_display: { $date: { value: { $ref: '$.close_date' }, format: 'MMM D' } } }]:createQueryDslasks the query for the stored fields a declared field reads, andcreateShapeMappercomputes the declared field with Prism, so the stored value beside it is never overwritten; a mapping a model wrote overwrote it about a quarter of the time.normalizeShapekeeps a declared field whole, so two formats are two cache entries.A flat shape whose fields the first row carries, each plain one of the kind its example shows, is mapped with no model call, as rows that fit the shape exactly were; rows with more columns than the shape names are now mapped the same way rather than by the agent. Any other shape — nested, a field the rows lack, a value of another kind — goes to the mapping agent, whose mapping is run on the rows before it is used: one that fails there is written again, the agent told why, up to three attempts in all. The agent is told the rows already answer the request (filtered, sorted, limited) and keeps every row, in order.
1c78d6d: The engine hands
mapToShapethe request's intent, andcreateShapeMappergives it to the mapping agent.MapToShapeis(rows, shape, intent?): the engine passes the intent a query was asked in, so the mapping can give a value the form the request named (money as$1.2M, a date asApr 6). Before, the mapper saw the rows and the shape alone, whose placeholders say nothing of a form.createShapeMapperpasses it to prism'smappingAgentas itsnotes. A mapper that takes two arguments keeps working.
0.4.0
Minor Changes
4068f98: Vex keeps what an entry it writes was written from, and says who asks when it looks one up. Breaking.
BREAKING — approved by moccadroid, 2026-10-14: a
mapToShapean app writes answersconfig, the Prism config itsirwas compiled from, besideirandtransformed.createShapeMapperdoes.- A query vex writes keeps its mapping's Prism config (
mapping, on the entry), beside the compiled form. A compiled form can neither be read back as a config nor upgraded when Prism changes. - The Postgres cache's table gets a
mappingcolumn, in a new migration,nisc.vex.cache/2. A deployment that runs its migrations before it boots runs that one first. CacheBackend.get(key, scope?): the two lookups that replay an entry hand the cache the scope of whoever asks, so a cache can answer an entry to some callers and not to others. A backend that takes no scope works as before.@niscorp/vex/migrationsexportsVEX_SEQUENCE, one stored kind,nisc.vex/entry(an entry as written, itsmappinganisc.prism/config), andVEX_SCHEMAS.
- A query vex writes keeps its mapping's Prism config (
0.3.1
Patch Changes
f62ee50:
applyScopeis marked deprecated, and DESIGN.md describes the scope functions as they are.The engine checks access with
checkScopeand places row rules withscopeResolved, after resolution.applyScopestayed as an exported function that runs the access check and returns the query as it came, while DESIGN.md still described it as merging the row filters into the query. It now carries@deprecatedwith that sentence, and the Scope section of DESIGN.md describescheckScopeandscopeResolved.No behaviour changed.
What to change: nothing has to. A caller of
applyScopegets the same check fromcheckScope(entities, policy). A caller that relied onapplyScopeto filter rows was not being filtered: it goes through the engine, or callsscopeResolvedon the resolved query.b4d24e9: DOCS.md and DESIGN.md state twenty facts about the API that were written only in source comments.
Among them: the adapter's
excludeoption andstatementTimeouts: falseon a pool; whatscopeBindingsreturns; that a composite foreign key is one relation joined on every pair; that a behavior'swriterules apply to every write verb; when anupsertinserts; that nothing in vex callssweepCache; what the Postgres and tiered caches handonError; thatsignalandonChangeonly work together; what the hono adapter does whengetPolicyreturnsundefinedand when no policy covers a write; and what an adapter'slimitReadsandvalidateEntryreturn.No behaviour changed.
What to change: nothing.
bf6496f: A read with more than one parameterised subquery compiles, and each subquery binds its own values.
A subquery was compiled apart from its parent, so its parameters were numbered from
$1again while its values were appended to the parent's list. One parameterised subquery in first place worked. Two of them, or a parameter of the parent ahead of one, produced a statement Postgres refuses (bind message supplies 2 parameters, but prepared statement requires 1). A scope rule on a table inside a subquery is such a parameter, so two subqueries over scoped tables were refused as well.A subquery now compiles into its parent's parameter list, as an
existsalready did. A read that compiled before compiles to the same SQL.What to change: nothing. A read that was rewritten to avoid two parameterised subqueries (conditional sums over one source, say) can be written with the subqueries again; it does not have to be.
0.3.0
Minor Changes
f834f3f:
evaluatechecks a config object once and only evaluates it after that;prismTransformis gone. Breaking.BREAKING — approved by moccadroid, 2026-10-09: replace
prismTransformwithevaluate(transform: evaluate), and pass limits as{ limits: … }.Prism had three ways to run a config, and the one a host was told to hand to its seam (
prismTransform) was the one that reported worst, while the hosts on a hot path each wrappedevaluate, which checked the config on every call. There are two now, with one job each.evaluate(config, source, options?)— for a config a program holds.- The first time it is handed a config object it checks it and keeps its tree; the second time it prepares the tree as
compilewould; from then on a call only evaluates. Four fields picked and renamed, the same config object again: 3.5 µs before, 0.4 µs now. A nested choice on two fields: 18 µs before, 0.7 µs now. A config that is a new object on every call costs what it did. - It is kept by the object: a config changed in place after its first call is not read again. Hand over a new object.
- Both arguments are
unknown, so it is what a host's transform seam takes:createTide({ transform: evaluate }),createUpgrader(grammars, { transform: evaluate }). - The third argument is
{ limits?, check? }. Limits were the third argument themselves:evaluate(config, source, { maxSteps: 100 })is nowevaluate(config, source, { limits: { maxSteps: 100 } }). check: 'always'is for writing a config, for tests and for tools: nothing is kept, the config is checked on every call, and the source must be plain JSON (a source holdingundefined, a function, aDateor a non-finite number is refused withE_TYPE).- An invalid config is a
PrismError(E_SCHEMA) naming the part that is wrong, however deep the config.prismTransformthrew zod's own error, and aRangeErrorfor a config nested far past the limit.
evaluateSafetakes the same arguments.compileandexecute— for a config that is stored. Unchanged.prismTransformis removed, from@niscorp/prismand from@niscorp/prism/migrations. What it added overevaluatewas a check that the source is plain JSON; that check is nowcheck: 'always', and a seam's source is taken as it is.moss, nova, vex, loom, nisc, create-nisc and the cli move with it. Each has Prism as a peer or a dependency, or has one of those that do, and a Prism at 0.3 is outside the range their last versions accept: an app takes them together. Nothing in vex, loom or the cli changed beyond that range.
moss's shell and nova's
$prismbinding calledevaluateon every use and so paid the check each time: an endpoint's request and response under moss, and a$prismvalue on each draw and for each row of a list, are 8 to 45 times faster for the configs measured. tide's and strata's documentation nameevaluatewhere they namedprismTransform.What to change
import { prismTransform } from '@niscorp/prism'or'@niscorp/prism/migrations'→import { evaluate } from '@niscorp/prism', andtransform: prismTransform→transform: evaluate. Every app made bycreate-nischas this insrc/dev/strata.ts; the app's typecheck andnpm run strataboth stop on that line until it is changed.- A third argument of limits becomes
{ limits: … }. - A wrapper that parsed the config before calling
evaluate(evaluate(ConfigSchema.parse(config), …)) makes a new object on every call and so is checked on every call: handevaluatethe config itself. - Code that changes a config object in place and evaluates it again must hand over a new object, or pass
{ check: 'always' }. - A host that relied on
prismTransformrefusing a source that is not plain JSON checks it itself, or passes{ check: 'always' }.
- The first time it is handed a config object it checks it and keeps its tree; the second time it prepares the tree as
Patch Changes
- Updated dependencies [023bcf9]
- Updated dependencies [1db6df4]
- Updated dependencies [6d098e4]
- Updated dependencies [8b4d97e]
- Updated dependencies [f834f3f]
- Updated dependencies [b084e30]
- Updated dependencies [663d724]
- Updated dependencies [6512d91]
- Updated dependencies [7d682b9]
- Updated dependencies [d2e9d22]
- Updated dependencies [e0e8abc]
- Updated dependencies [7fe1135]
- @niscorp/[email protected]
0.2.3
Patch Changes
1e5ad2d: An insert requires every key its
valuesbind, whateveronConflict.setputs in the same column.An
insertorinsertEachmay set the same column invaluesand inonConflict.set. Vex read the two by column name, so where they put different things in a column it saw only whatonConflict.setput there. A$contextkey thatvaluesused for that column, and that nothing else in the statement used, was not in the entry's derived signature and was not required. A call that left it out was not refused. The statement ran with that parameter unbound, and a new row was written with NULL in the column — or, where the column is NOT NULL, the database's own error came back in place of vex's. DOCS.md says a write never executes with holes; now this one does not.{ op: 'insert', table: 'people', values: { email: { $context: 'email' }, name: { $context: 'name' } }, onConflict: { target: ['email'], set: { name: { $context: 'newName' } } } }- A call without
nameis refused before any SQL runs: 400missing_context,Mutation is missing context: name.(was 200, and a row withnameNULL). - Discovery,
collectMutationContextand the refusal'sdetails.expectedlist bothnameandnewName(listednewNameonly).requiredContextKeysreturns both. - The same where
onConflict.setputs a literal or a$lookupin the column rather than another key, and for the keys a$lookupinvaluesbinds.
An entry that puts different things in one column in the two places has its signature read
valuesfirst, thenonConflict.set. Its keys can come in another order than before, and a key used in both is listed with the column and type of its place invalues.Nothing else moves. An entry whose
valuesandonConflict.setshare no column, or put the same thing in every column they share (the create-or-fetch touch,set: { email: { $context: 'email' } }), lists and requires what it did, in the same order.update,deleteandmutationEffectare unchanged.What to change: nothing, unless an entry sets a column in
valuesfrom a$contextkey, sets the same column inonConflict.setfrom anything else, and some caller leaves that key out. That call wrote a NULL and is now answered 400. Send the key —nullto write NULL on purpose; only an absent key is refused — or, if the column was never meant to be set on insert, take it out ofvalues.- A call without
2011951: A mutation batch can use what an earlier statement wrote:
{ $returned: 'table.column' }.A batch ran in one transaction but no statement could see another's result. A row whose id the database generates could not be referenced by the rows written after it in the same batch.
A later statement of a batch can now read a column of the one row an earlier statement wrote:
[ { op: 'insert', table: 'orders', values: { note: { $context: 'note' } } }, { op: 'insertEach', table: 'order_lines', items: { $context: 'lines' }, values: { order_id: { $returned: 'orders.id' }, sku: { $item: 'sku' } }, }, ];The reference names the earlier statement — an insert, update, upsert or delete — by the table it writes. It goes in any value position (
values,set,onConflict.set, anupsert'scolumnsandinsert), and not in awhere, a$lookup, or aninsertEach'sitems. It is for keys: the value passes through the driver, and a timestamp read back that way keeps milliseconds only.The statement it names must write exactly one row. When it wrote none (its WHERE matched nothing, or the scope boundary kept the row back) or several, the batch fails with
execution_errorand nothing is written.lintMutation— soseedCache— refuses a reference that no earlier statement can answer, that two earlier statements could answer, that names aninsertEach, or that names aninsertwhoseonConflicthas noset. Scope is unchanged: each statement is scoped on its own, as before.Every existing entry, request and reply behaves as it did.
MutationValuegains one union member,ReturnedRef.What to change: nothing. To use it, upgrade every process that reads the cache first: a vex older than this one deletes a stored entry whose grammar it does not know, as it does for any form added since it was built.
2c57a38: Under
require, an engine error on a read keeps its HTTP status.The CommonJS build gives each entry point its own copy of
VexError, and the HTTP handler recognised an engine's error byinstanceof. With the engine fromrequire('@niscorp/vex')and the endpoint fromrequire('@niscorp/vex/hono')orrequire('@niscorp/vex/express'), every error the engine threw on a read was therefore answered as an unexpected one: 500, without itsdetails, and with a[vex] unhandled errorline in the log. The ESM build has one copy of the class and answered as DOCS.md says. Now both do:- an unknown fingerprint is 404
cache_miss(was 500); - a protected entry asked to change is 409
fingerprint_protected(was 500); locked,agent_failed,unsatisfiable,scope_denied,invalid_dsland anexecution_errorthrown by the engine are 400, withdetailswhere the error has them (were 500, without);missing_scopeis still 500, and its reply no longer names the scope keys the host left out — they go to the log, as they always did under ESM.
The same was true of an app that loads vex in both formats at once, and of moss loaded with
require(its/api/…/vexendpoints). Writes were never affected: the handler runs them itself.The engine has the same check for a generation hook's
unsatisfiable. With the hook fromrequire('@niscorp/vex/agent')it never matched, so nothing was negative-cached and the model was asked again on every repeat of a request it had already called impossible. The refusal is cached now, forunsatisfiableTtlMs(five minutes unless set), as it is under ESM.Vex now recognises its own errors by a mark every copy of the class carries. Nothing is added to the exports, and nothing an error prints or serializes changes. An app's own
err instanceof VexErroris as it was: it holds for an error the engine throws, and underrequireit still does not hold for one made in@niscorp/vex/agent— readerr.codethere (DOCS.md, "Error handling").What to change: nothing in an ESM app, and nothing in a CommonJS app that calls
handleQueryfrom@niscorp/vexitself. A CommonJS app that had come to depend on those 500s now sees the documented status: a client that reads 500 as "unknown fingerprint", a retry or an alert on 5xx, a log search for[vex] unhandled error.- an unknown fingerprint is 404
2d20daf: The Hono adapter answers a body that is not JSON with 400
invalid_request, not 500.POST,PATCHandDELETEparsed the body unguarded, so a parse failure threw out of the route: text, a multipart or urlencoded form, truncated JSON, or no body at all was answered500 Internal Server Errorin plain text, with theSyntaxErroron the server's log — a server fault for what is the request's mistake, and the one failure on this surface without the{ error, message }shape. It is now400 { "error": "invalid_request", "message": "Body must be JSON" }, on a locked endpoint as well. A moss app's vex surfaces (/api/vex,/api/<resource>/vex) are this adapter, so they answer the same.Nothing else moves. Every request that was answered before is answered the same, byte for byte: JSON of the wrong shape keeps its own 400; JSON sent as
text/plainor with no content type is still read (the content type is never looked at);getScopestill runs before the body is read;onExecutehears nothing for a body that was never a request, as before. The body is still read through hono's ownc.req.json(), so a middleware that read it first shares hono's cache with the adapter exactly as before, on every hono 4. Only the parse failing is caught — a body that cannot be read at all, or a stream that broke, still throws. The Express adapter was not affected: it is handedreq.bodyand already answered 400.What to change: nothing, unless something of yours was built on the 500. A host's own
onError(or a middleware readingc.error) no longer sees aSyntaxErrorfor these requests — the response is the 400 above, not the one the handler made. A client that retried on 5xx stops retrying them. A nova endpoint aimed at a vex url with norequestdeclared now fails with@error.status400 and the messageBody must be JSON, where it had 500 andHTTP 500.b711c38: The reply says when the engine's own limit may have cut a list.
A list query that states no
limitis givendefaultLimit(100), and a generated one asking for more thanmaxLimitis clamped. Either way the reply carried the rows and nothing else: a list of 100 looked like a list that had 100 things in it.When the
LIMITa query ran with was the engine's number — the default, or the maximum — and exactly that many rows came back for a list,meta.warningsnow carries one line saying the list may have been cut and which number did it. For a request that named a fingerprint the same line is written once per fingerprint withconsole.warn, because a host that hands callersresultwithoutmetawould show it to no one.It says "may": no extra row is fetched to find out, so a list of exactly that many rows gets the line too. A list that ended on a
limitits author stated gets none, and neither does a single-row answer. Noresultchanges, and no query reads a different number of rows.What to change: nothing. If the line appears for one of your entries, state the
limitthat list should have.3d8518c:
mutationMisfits(def, schema): why a stored write does not fit a schema, without running it.Every write passes two column gates before it runs: its table, the columns it writes, its WHERE, its
$lookups and its conflict target must be the schema's, and so must a column it reads from an earlier statement ($returned). Those gates could only be met by running the write.mutationMisfitsasks them and runs nothing: one sentence for each refusal, none when the write fits. An upsert is asked as both statements it can become. Scope is not applied — what a policy stamps or bounds is checked where the principal is known.What to change: nothing.
b711c38: A seeded entry runs with the
limitits author wrote, pastmaxLimit.maxLimit(1000 by default) clamped every query, including one a developer wrote and seeded: a seeded entry that stated a largerlimitran withmaxLimitin its place, and the reply did not say so.maxLimitnow clamps only a query nobody reviewed — one generated for a request. An entry seeded withseedCacheruns with thelimitit states. Which kind an entry is comes from the stored row and not from the request: protected, with no request hash. That is whatseedCachewrites, and what a host writes when it stores an entry through the cache backend itself; no request can produce it. A generated entry stays clamped when it is replayed, when it is stored under a name the caller chose, and when it is protected afterwards.engine.compiletakes a bare query and clamps as before;engine.teststill runs with a limit of 5.One edge: a generated entry that was protected and is then seeded with the identical definition keeps its request hash, because
seedCacheleaves a row that already matches alone. It stays clamped, and its reply says so, until it is unprotected or deleted and seeded again.This changes what an existing app receives in one setup: a read entry the host stored itself — seeded, or written protected through the cache backend — whose
limitis above the engine'smaxLimitreturnedmaxLimitrows and now returns up to its statedlimit. Nothing changes for an entry that states no limit, one that statesmaxLimitor less, a generated query, or a mutation.What to change: nothing, if the larger answer is what the entry was written for. To keep the clamp on seeded entries, set
config.capAuthored: trueon the engine (under moss:runtime.vexConfig: { capAuthored: true }).1903342: An upsert's signature lists the key its
columnsbind, also for a column itsinsertsets too.An
upsertmay set the same column incolumnsand ininsert: the first is written when it updates, the second when it creates. Its derived signature read the two merged by column name, so where they used different$contextkeys only the one ininsertwas listed. The key the update binds was missing from discovery and fromcollectMutationContext— and fromdetails.expectedof the verymissing_contextrefusal that named it. What an upsert requires was right all along, on both branches; only the listing was short.{ op: 'upsert', table: 'people', key: 'id', columns: { name: { $context: 'name' } }, insert: { name: { $context: 'firstName' } } }- The signature lists
namebesidefirstName(listedfirstNameonly).firstNameis still markedinsert only. - Where both halves use the same key for the column, that key is no longer marked
insert only: the update binds it too.
Nothing else moves. An upsert whose
columnsandinsertshare no column is listed as it was, and no write runs, is refused or lands differently. Inside, the signature, the required keys and the$returnedcheck now read what a statement sets through one function.What to change: nothing.
- The signature lists
0.2.2
Patch Changes
008b5e8: README and DOCS corrections. The exported
Querytype requiresdiron asortentry; in hand-written JSON it may be left out and reads asasc.compileandtestwork with no model, not with no database: both need the introspected schema, andtestruns the query. The "N counts in one row" example compiles but throws at execute (08P01) under a policy that row-scopes two of its tables, because each such subquery binds its own$scopeparam; under such a policy, read each count on its own. No code changed.What to change: nothing.
0.2.1
Patch Changes
fe30458: Every package exports its
package.json, so an app can say which version of a package it runs by reading it from the package:import prism from '@niscorp/prism/package.json' with { type: 'json' }; prism.version; // the version that is installed, not the range that asked for itUntil now the
exportsmap hid it, and the only way to the version was a path intonode_modules.check:packagesinstalls the tarballs and reads every package's version this way.What to change: nothing. An app that read a version by path can read it by name.
1e6d55d: A pool's
queryandtransactionare called on the pool, and a transaction'squeryon the transaction — never taken off the object first. A driver's own object has them as methods that need their receiver, and taken off they failed inside the driver, on a property nothing in nisc names:- strata —
migrateandupgradeStoreover aPGlitethrewCannot read properties of undefined (reading '_checkReady');statusandreadLedgerdid too, once a ledger existed. A pool whosetransactionhands a checked-outpgclient through as the transaction failed inmigratethe same way. - moss —
createTideStoretookqueryoff the transaction. Over a pool that hands its client through, every transaction of the store failed inside the driver, and tide recorded the run as deferred: nothing threw, and the effect never ran. - vex —
createPostgresAdapterwith a read limit set (limitReads) threw on a pool whosetransactionis a method.
A
PGlitenow works as a pool as it is, and so does apgwrapper that passes its client through. A pool built from closures (createPglitePool, a wrapper that builds its ownquery) behaves as before.What to change: nothing.
- strata —
5c70059: Vex hashes without
node:crypto, so@niscorp/vexand@niscorp/vex/pglitebuild for a browser as they are.Every identity vex computes —
computeRequestHash,computeSchemaFingerprint, the policy key behind the negative cache, a mintedfp_…, and the row and answer hashes of a reactive read — is a SHA-256, and it came fromcreateHashinnode:crypto. A page has no such module, so a bundler refused the package ("createHash" is not exported by "__vite-browser-external"), and every app that runs vex over PGlite in the browser pointed its bundler'scryptoat a shim of its own.The hash is now vex's own (
src/utils/sha256.ts): synchronous, self-contained, and the same digests as node's byte for byte. Nothing stored moves — a cache row'srequest_hashandschema_fingerprint, aneg:key and a fingerprint computed at build time by Node all still match what a page computes. The exported functions keep their signatures. It costs about three times node's own hash: 0.003 ms for a request identity, about 0.6 ms per 100 KB of rows.What to change: nothing. An app that aliased
crypto(ornode:crypto) to a shim for vex can delete the alias, the shim and the dependency behind it.
0.2.0
Minor Changes
f1cec45: A Prism config that validates no longer names an op Prism does not have.
validateaccepted{ card: { $fetch: … } }and{ $eval: "…" }: the plain-object branch refused only the names of ops that exist, so any other$key passed as a template key. Such a config could be stored — an endpoint request, a vex mapping, a strata migration — and then failed every time it was evaluated (E_NODE_SHAPE, "Unsupported node shape").A key that starts with
$is now an op's name and nothing else. The template branch refuses every$key — the rule the evaluator already held — sovalidatereports it with the key and where it is (card.$fetch: Not a Prism op. A key that starts with "$" names an op; data with such a key goes in $const.), andevaluateandcompilefail withE_SCHEMAbefore anything runs. An op's name used as a template key keeps its own message.E_NODE_SHAPEis left for a tree that never went through the schema: an IR handed toexecute. Adding an op no longer takes a key away from templates.- prism — the change, and a grammar narrowing:
nisc.prism2, a marker with no document step (a key that never evaluated has nothing to be rewritten to).$keys that are data are untouched: a$constpayload,$withnames,$renameKeysand$fromEntrieskeys. The JSON Schema's template key pattern is^(?!\$)instead of a list of every op. - nova, vex, moss, loom, cli, nisc — no change of their own. They depend on prism and are released with it, so the set installs together; the configs they hand to prism (endpoint
requestandresponse,$prismbindings, vex mappings) are held to the same rule. - create-nisc — the templates' sources are recorded at
nisc.prism2.
What to change: in a config with a
$-prefixed key that is not a Prism op, remove the key — or put the object in$constif it is literal data. An app that keeps astrata.lock.jsonrunspnpm strata upgrade, thenpnpm strata verify; there is nothing to edit unless it has such a config. Everything else: nothing.BREAKING — approved by moccadroid, 2026-10-04: a config with a
$-prefixed key that is not a Prism op no longer validates, compiles or evaluates — also where evaluation never reached the key (an untaken$casebranch, a short-circuited$or, a$mapbody over an empty array), which used to work. A vex seed mapping with one fails when it is seeded, at boot, instead of on each replay. Code that matchedE_NODE_SHAPEfromevaluategetsE_SCHEMA. The@niscorppackages that depend on prism move with it, so an app moves them together (@niscorp/nisc0.3.0).- prism — the change, and a grammar narrowing:
Patch Changes
- Updated dependencies [f1cec45]
- @niscorp/[email protected]
0.1.1
Patch Changes
- 45d6731:
DELETEon an unlocked fingerprint endpoint refuses a protected mutation entry with 409fingerprint_protected, as it already did a protected read. A seeded write could be evicted by anyone who could reach the endpoint. A locked endpoint (every endpoint moss serves) was never affected.