Skip to content

feat(tendlc)!: command-tree cutover and number reads (PR 5 of 5) - #41

Merged
kshahbw merged 15 commits into
mainfrom
feat/tendlc-cutover
Aug 24, 2026
Merged

feat(tendlc)!: command-tree cutover and number reads (PR 5 of 5)#41
kshahbw merged 15 commits into
mainfrom
feat/tendlc-cutover

Conversation

@kshahbw

@kshahbw kshahbw commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

What this adds

The last PR in the 10DLC series. band tendlc number (list/get/history), the legacy command tree deleted outright, a doc-contract test that can finally catch stale command references, and a self-healing retry that neutralizes the one API change most likely to break this CLI.

PR 5 of 5. Stacked on #40 — review that first.

Paired with an api-specs change that must not merge alone. Branch docs/tendlc-number-cli-reference is prepared locally and unpushed. Publishing docs first describes commands the released CLI does not have; publishing the CLI first orphans four reference pages. They ship together or not at all.

The breaking change

Deleted with no aliases: band tendlc campaigns, campaigns numbers, numbers, number <tn>. The deprecation window was PRs 3 and 4, where both trees coexisted deliberately. band tendlc now lists exactly brand, campaign, number, status, vetting.

Deleting them surfaced a bug worth naming: removed commands exited 0. band tendlc campaigns printed help and returned success while band notacommand correctly exits 1 — so for an agent, a deleted command was indistinguishable from a working one. The cause is subtle: cobra's execute() checks Runnable() and short-circuits before ValidateArgs ever runs, which makes Args: cobra.NoArgs dead code on a command with no RunE. Fixed on all five dispatchers.

The doc-contract test could never have caught this

It only errored when a documented path matched zero command tokens. Since tendlc stays valid, band tendlc campaigns list matched one token and passed — so the test was structurally blind to exactly the drift this PR creates.

The obvious tightening is wrong: commandTokenRe is ^[a-z][a-z-]*$, so lowercase positionals parse as command tokens and valid lines like band auth use admin would fail. The fix resolves as far as possible, checks whether the resolved command's Use declares a placeholder, then calls the command's own Args validator on the remainder — cobra's real answer rather than another heuristic.

knownDriftCommands is empty. Every suppression was removed rather than deferred, including one that turned out to be a shell comment the parser was reading as a command. Verified by planting three drift shapes — deleted subcommand, deleted positional form, bare argument on a parent — and watching each fail.

The retry, and the bug the review caught in it

brand update and campaign update build a lossless full-replacement PUT body that includes fields the API currently tolerates. If that is ever tightened to a 400, both break the same day. The retry recognizes that specific 400, drops the named fields, retries once, and says so on stderr.

The whole-branch review found it could fire on the wrong 400. Its discriminator was "error pointers naming fields in the body" — but that body holds the caller's own edits too, and all 23 caller-settable brand fields sit outside the read-only list. So brand update --website "not a url" would have deleted website from the retry body and, on a full-replacement PUT, cleared it — exiting 0 with a success receipt.

Now the retry can only ever drop a field the caller did not ask about. The implementer also caught that neither route I suggested would protect campaigns' four compliance attestations, which have no update flag at all and so can never be "caller-set" — they needed an explicit never-drop set. Its correction was right and my brief was incomplete.

Testing

Six tasks, each independently reviewed with its own fix loop, then a whole-branch review, a fix wave, a scoped re-review, and an independent adversarial pass.

Worth noting what the reviews caught, because the pattern is consistent: an implementer found a cobra routing collision where a second command named number would have silently shadowed the legacy one; another found its own escaping test passed with the escaping removed, because Go's net/url re-derives canonical encoding. The putretry suite passed only because every fixture used invented field names no caller could set — the fixtures could not express the bug that was there.

Also in here

Two real Bandwidth phone numbers were sitting in AGENTS.md examples, pre-existing on main. Replaced with reserved-range placeholders. Three references to deleted commands in cmd/message/preflight.go — user-facing remediation advice on a failed message send — repointed; the doc-contract test never reads Go string literals, which is why nothing caught them.

Live probing added findings to the spec-vs-production log, now at 46 across 12 sections, including one retraction: a filter I had reported as broken turned out to work, because my probe had filtered on a campaign with no assigned numbers and I read the empty result as a failure. An empty result proves a filter matched nothing, not that the filter is broken.

@kshahbw
kshahbw requested review from a team as code owners August 24, 2026 15:46
@bwappsec

bwappsec commented Aug 24, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues
Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@kshahbw

kshahbw commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Merge notes.

1. Squash-merge this PR. Intermediate commits contain doc transcripts and a retry guard that were corrected in later commits; the final tree is clean.

2. Do not merge without the paired api-specs PR. Branch docs/tendlc-number-cli-reference regenerates the CLI reference and drops four pages for commands this PR deletes. It is prepared but unpushed — publishing either side alone leaves the reference and the CLI describing different products.

3. This is the breaking change in the series (feat(tendlc)!). Four commands are removed with no aliases.

@kshahbw

kshahbw commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Independent adversarial pass (codex) — one Critical, and it was real

Ran a fresh reviewer over the branch after six per-task reviews, a whole-branch review, a fix wave, and a scoped re-review had all cleared it. It found a data-loss bug every one of them missed. Verified all three findings against the code before acting.

The retry still erased unchanged customer data

The previous round narrowed the retry so it could not drop a field the caller changed in this invocation. That is not the right test, and the framing error was mine — I offered two routes and the narrower one was taken.

An unchanged field still holds real data. Concretely: a brand has a website set months ago that no longer passes current validation. The user runs brand update --display-name "New Name", not touching the website. The PUT 400s pointing at /website. Because the caller did not change it, website was not protected — so the retry dropped it, and a full-replacement PUT nulled it. Exit 0, success receipt, and a stderr note claiming we dropped a field "the API rejected but does not need."

Same exposure on sample2, helpMessage, privacyPolicyLink, and every other optional mutable field.

neverDrop is now built from the entire update flag surface. The invariant is stated in putretry.go in these words: the retry may only ever drop a field the CLI does not model at all — which is the only scenario it was ever designed for. Verified both directions: flag-reachable fields are protected, and a genuinely unmodeled field is still droppable, so the retry has not been disabled in the name of fixing it.

Punctuation bypassed the doc-contract guard

This line passed the check:

Use `band tendlc campaigns,` then assign the number.

The comma made campaigns, fail the command-token test, so the parser resolved only tendlc and then abstained on the punctuated remainder — letting a deleted command through, which is precisely the class the parser exists to catch. Trailing punctuation is now stripped before the decision. knownDriftCommands remains empty.

Two documentation slips

A port-in example that exists to demonstrate multiple numbers was showing the same number twice — collateral from my own placeholder sanitization sweep. And one caller-id was outside the reserved range.


Worth stating plainly: this is a second fix wave, which the review process I was following allows only one of. I took the deviation deliberately rather than park a known data-loss bug behind a process rule.

@kshahbw
kshahbw disabled the stack merge August 24, 2026 17:53
@kshahbw
kshahbw disabled the stack merge August 24, 2026 18:28
@kshahbw
kshahbw force-pushed the feat/tendlc-cutover branch from 5907f7c to 3683456 Compare August 24, 2026 18:34
@kshahbw
kshahbw force-pushed the feat/tendlc-cutover branch from 3683456 to 406b36c Compare August 24, 2026 18:36
@kshahbw
kshahbw force-pushed the feat/tendlc-cutover branch from 406b36c to 1597861 Compare August 24, 2026 18:38
Base automatically changed from feat/tendlc-campaigns to main August 24, 2026 18:42
kshahbw added 14 commits August 24, 2026 14:42
…rojection docs

Re-measurement showed campaignId[contains] filters correctly (the earlier
zero-match probe used a campaign with no assigned numbers) and the list
projection has two shapes, not a fixed five keys (assigned numbers carry
three extra fields). status remains confirmed dead under every operator
and value, so --status stays absent.
Two claims in ListPhoneNumbers' doc comment were wrong, both from probe
design rather than API behaviour.

The projection is conditional, not fixed: 16 of 23 records carry five keys,
the 7 assigned to a campaign carry three more. The original claim came from
a single record fetched with limit=1.

campaignId[contains] works correctly. The original probe filtered on a
campaign with no assigned numbers, got zero results, and read the empty set
as a broken filter. Re-tested against a campaign with three numbers, it
returns three.

status genuinely does not filter under any operator, including a value that
matches nothing on the account.
Deletes band tendlc campaigns, campaigns numbers, numbers, and the flat
number <tn> command, ending the deprecation window from the two previous
PRs where the legacy and new command trees coexisted deliberately. No
aliases or shims: v0.3.0-beta status and no substantial customer traffic
justify a clean break here.

number.go's three subcommands (list/get/history) now attach to a plain
numberCmd parent registered directly on Cmd, matching brandCmd/campaignCmd,
instead of the numberGetCmd node that lived in the now-deleted numbers.go.

extractData and filterNumbers in helpers.go existed only to serve the
deleted commands and are removed along with their tests. Also folds in a
carried-over test assertion: number list must not gain an exact-match
--campaign-id flag, since campaignId[eq] is silently ignored by the API
and returns every record — the same anti-pattern already avoided by
brand list and campaign list.
band tendlc campaigns, band tendlc numbers, and band tendlc number <tn> --
all removed in the previous commit -- exited 0 with a help dump instead of
failing, because cobra checks Runnable() before it ever consults Args, and
none of Cmd/brandCmd/campaignCmd/numberCmd/vettingCmd had a RunE. A stray
token that used to be a real, now-deleted command was indistinguishable
from a successful help request.

Gives each of those five dispatcher commands Args: cobra.NoArgs plus a
trivial RunE (return cmd.Help()) so NoArgs actually runs: a bare invocation
still prints help and exits 0, but a trailing token matching no subcommand
now exits non-zero. customer-profile has the same latent shape but is out
of scope for this branch.
…-contract parser

Adds a third gate that calls the resolved command's real cobra Args
validator against the documented arguments, catching stale references
that resolve completely (so the Use-string heuristic sees no remainder
to judge) but whose command no longer accepts what follows it.
…sion

The parser fix landed with five knownDriftCommands entries: four for real
drift the deletion left behind, one for a shell comment that parsed as a
command. All five are now unnecessary.

The six stale lines are rewritten to the new tree, the numbers block no
longer advertises a --status filter the API silently ignores, and the
parser skips shell comments inside fenced blocks — prose that mentions a
command mid-sentence is not an invocation.

knownDriftCommands is empty. Verified by planting three shapes of stale
reference and confirming each is caught: a deleted subcommand, a deleted
positional form, and a bare argument on a parent that takes none.
brand update and campaign update build a full-replacement PUT body by
stripping a known list of read-only keys from the resource the API just
returned. That only works because production currently accepts read-only
fields it does not use. If that is ever tightened to a 400, both commands
break the same day, since the strip lists cannot enumerate every field the
API might start rejecting.

Add putReplaceWithReadOnlyRetry, shared by UpdateBrand and UpdateCampaign:
on a 400 whose error source.POINTER values name top-level fields present in
the outgoing body, strip exactly those fields and retry once, noting the
drop on stderr. Any other 400, or a retry that also fails, surfaces
untouched/original. No loop, no backoff, no mutation of the shared strip
lists.
Pre-existing on main, in the band number list example. This repo is public
and the 919 numbers are real Bandwidth TNs; the rest of the docs already
use the reserved 555-0100 block.
An independent adversarial pass found the previous guard insufficient. It
protected fields the caller changed in this invocation, but an unchanged
field still holds real data: a brand with a stored website that no longer
passes validation would have that website dropped from the retry body by
an unrelated --display-name update, and a full-replacement PUT nulls it.

neverDrop is now built from the entire update flag surface, so the retry
can only ever drop a field the CLI does not model at all — which is the
only case it was designed for. The invariant is stated in putretry.go.

Also: trailing punctuation no longer bypasses the doc-contract parser, so
a documented 'band tendlc campaigns,' is caught rather than abstained on;
a port-in example shows two distinct numbers again; and a caller-id moves
into the reserved range.
The doc-contract parser is line-oriented and splits on "\n". On a Windows
checkout the files land with CRLF, so the trailing "\r" survives the split
and glues itself to the last token on every line.

That broke the gate two ways. A command token became "get\r", which fails
commandTokenRe, so the path resolved one token short and the test reported
`band tendlc campaign` rejecting "get". And the lone "\" shell
line-continuation marker became "\\\r", which no longer matched the
documented abstain rule for it, so multi-line examples were parsed as if
the continuation backslash were a real positional argument.

Four AGENTS.md examples failed this way on windows-latest only. Normalize
once at the read site rather than defending against "\r" at each token
check downstream.

Verified: passes under LF, passes under simulated CRLF, and still catches
a planted `band tendlc brandz list` under CRLF -- the normalization does
not neuter the gate.
@kshahbw
kshahbw force-pushed the feat/tendlc-cutover branch from 1597861 to 19f463b Compare August 24, 2026 18:42
@kshahbw
kshahbw merged commit 573ac80 into main Aug 24, 2026
8 checks passed
@kshahbw
kshahbw deleted the feat/tendlc-cutover branch August 24, 2026 18:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants